# User Preferences Service Low-Level Design: Hierarchical Defaults, Real-Time Sync, and Conflict Resolution

Source: https://www.techinterview.org/post/3233469513/lld-user-preferences/
Updated: 2026-06-30 · techinterview.org

## Preference Hierarchy

A user preferences service must resolve the right value for any preference key given a user and device context. The hierarchy from lowest to highest priority:

- **System default:** Hardcoded in application code. Applied when nothing else is set.

- **Account-level preference:** User set this explicitly and it applies across all devices.

- **Device-level override:** Set on a specific device. Overrides account-level for that device only.

Resolution order: check device scope first → fall back to account scope → fall back to system default. This lets users set "dark mode everywhere" at the account level while overriding to "light mode" on a specific shared device.

## Preference Schema


```
CREATE TABLE preferences (
  user_id      BIGINT NOT NULL,
  pref_key     VARCHAR(128) NOT NULL,
  pref_value   JSONB NOT NULL,
  scope        ENUM('account','device') NOT NULL,
  device_id    VARCHAR(64),  -- NULL for account scope
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, pref_key, scope, device_id)
);
```

Using JSONB for `pref_value` allows storing strings, booleans, numbers, and arrays without schema changes for each new preference type.

## Storage: Redis + PostgreSQL

- **PostgreSQL:** Source of truth. Durable, queryable, supports partial updates and audit logging.

- **Redis:** Hot read cache. Store all preferences for a user as a Redis hash: `HSET prefs:{user_id} {pref_key} {value}`. Single `HGETALL` returns all prefs in one round-trip.

Cache TTL: 24 hours with invalidation on write. Read path: Redis lookup → DB fallback → system default constant. Write path: update DB → delete Redis cache key → publish change event.

## Read Path

- Check Redis: `HGET prefs:{user_id}:{device_id} {pref_key}`

- Check Redis: `HGET prefs:{user_id}:account {pref_key}`

- Query PostgreSQL for both device and account rows.

- Populate Redis cache for next request.

- Apply hierarchy resolution: device wins over account; account wins over default.

- Return system default if no DB row exists.

Bulk read: `GET /preferences?device_id=abc` returns all preferences already resolved through the hierarchy — the client does not need to implement resolution logic.

## Write Path and Partial Update API

Use `PATCH /preferences` with a delta object to support partial updates. Merge semantics: only specified keys are updated, others are untouched.


```
PATCH /preferences
{
  "scope": "account",
  "preferences": {
    "theme": "dark",
    "notifications_enabled": true
  }
}
```

Implementation: `INSERT INTO preferences ... ON CONFLICT DO UPDATE SET pref_value=EXCLUDED.pref_value, updated_at=now()`. After DB write: delete affected Redis keys, publish preference change event to message bus.

## Real-Time Sync Across Devices

When a user changes a preference on their phone, their open browser tab should reflect it immediately.

- Client establishes a WebSocket connection on login.

- Server subscribes the connection to a Redis pub/sub channel: `prefs:changes:{user_id}`.

- On preference write, publish a change event to that channel.

- All connected devices for that user receive the update and apply it locally.

Event payload: `{"pref_key": "theme", "pref_value": "dark", "scope": "account", "updated_at": "..."}`. Clients apply the update to their local preference state without a full reload.

## Conflict Resolution

When two devices write the same preference concurrently (e.g., offline edits):

- **Last-write-wins (LWW):** Compare `updated_at` timestamps. Higher timestamp wins. Simple and sufficient for most preference conflicts — the stakes are low.

- **Vector clocks:** For offline sync scenarios where timestamps cannot be trusted (clock skew), use vector clocks to detect true concurrency. More complex; use only if offline edit support is a hard requirement.

For account-scope preferences, LWW is always correct. For device-scope preferences, conflicts cannot occur by definition (only one device writes to its own scope).

## Default Registry and Versioning

System defaults are defined in code, not in the database. This avoids the need to seed preference rows for every new user. A `DEFAULTS` map in the application contains every known preference key and its default value. When no DB row exists, the service reads from this map.

Preference schema versioning: add new keys freely without migration. Removing a key is a breaking change — deprecate by ignoring the old key in resolution, not by deleting rows. Rename keys by supporting both old and new key names during a transition period.

## Preference Categories

- **Display:** theme (light/dark/system), font_size, language, timezone

- **Notifications:** email_enabled, push_enabled, digest_frequency

- **Privacy:** analytics_opt_out, data_sharing_consent, cookie_preferences

- **Accessibility:** reduce_motion, high_contrast, screen_reader_hints

Grouping preferences into categories allows bulk operations: "reset all display preferences to defaults" without touching notification settings.

See also: [Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence](https://www.techinterview.org/post/3233460274/netflix-interview-guide-2026-streaming-architecture-recommendation-systems-and-engineering-excellence/)

See also: [Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering](https://www.techinterview.org/post/3233460275/scale-ai-interview-guide-2026-data-infrastructure-rlhf-pipelines-and-ml-engineering/)

See also: [Anthropic Interview Guide 2026: Process, Questions, and AI Safety](https://www.techinterview.org/?p=3233460282)
