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}. SingleHGETALLreturns 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_attimestamps. 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.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How does the preference hierarchy resolve conflicts between account and device settings?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Preferences are layered — defaults < account-level < device-level < session-level — and the read path merges them by overriding each layer with the next, so a device-specific dark-mode setting beats the account default without deleting it. This allows a user to reset to account defaults by clearing the device layer without losing the underlying configuration."
}
},
{
"@type": "Question",
"name": "How are preference changes synced across devices in real time?",
"acceptedAnswer": {
"@type": "Answer",
"text": "On write, the preference service publishes a change event to a per-user topic in a message bus (Kafka or Redis Streams); each authenticated device session subscribes to that topic via a WebSocket or Server-Sent Events channel and applies incoming deltas to its local state. This fan-out architecture means all active sessions reflect a change within milliseconds without polling."
}
},
{
"@type": "Question",
"name": "How is last-write-wins conflict resolution implemented?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Each preference write carries a client-generated or server-assigned logical timestamp (millisecond epoch or Lamport clock); the storage layer compares the incoming timestamp against the stored one and discards the write if it is not strictly greater. This requires wall clocks to be synchronized within acceptable skew (NTP) or a server-side sequence generator to be the authoritative source of timestamps."
}
},
{
"@type": "Question",
"name": "How are user preferences cached for low-latency reads?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The full merged preference object for a user is serialized and stored in Redis with a key of user:{id}:prefs and a TTL of minutes to hours; on cache miss the service reconstructs it from the database, writes it back to the cache, and returns it. Write-through or write-invalidate strategies are both common — write-invalidate is simpler but causes a brief miss window; write-through keeps the cache always warm at the cost of a synchronous double write."
}
}
]
}
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