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

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:

  1. System default: Hardcoded in application code. Applied when nothing else is set.
  2. Account-level preference: User set this explicitly and it applies across all devices.
  3. 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

  1. Check Redis: HGET prefs:{user_id}:{device_id} {pref_key}
  2. Check Redis: HGET prefs:{user_id}:account {pref_key}
  3. Query PostgreSQL for both device and account rows.
  4. Populate Redis cache for next request.
  5. Apply hierarchy resolution: device wins over account; account wins over default.
  6. 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

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

See also: Anthropic Interview Guide 2026: Process, Questions, and AI Safety

Scroll to Top