Notification Preferences System Low-Level Design

What are Notification Preferences?

Notification preferences let users control which notifications they receive, via which channel (email, push, SMS, in-app), and at what frequency. Without a preferences system, users receive every notification and churn by unsubscribing entirely. A well-designed preferences system increases engagement by letting users receive only what matters to them. LinkedIn, Slack, and Gmail all have sophisticated notification preference systems.

Requirements

  • Users can toggle notification types on/off per channel (email, push, SMS, in-app)
  • Support notification categories: marketing, security, social, product updates, reminders
  • Frequency controls: immediate, daily digest, weekly digest
  • Do Not Disturb (DND) hours: no push/SMS during 10pm-8am local time
  • Unsubscribe from all for a channel (e.g., global email unsubscribe)
  • Preference lookup must be fast (<5ms) since every notification send checks preferences

Data Model

NotificationCategory(category_id UUID, name VARCHAR,    -- 'security_alert', 'marketing'
                     description VARCHAR, default_enabled BOOL,
                     can_disable BOOL)    -- security alerts may be mandatory

UserNotificationPrefs(pref_id UUID, user_id UUID,
                      category_id UUID,
                      channel ENUM(EMAIL, PUSH, SMS, IN_APP),
                      enabled BOOL,
                      frequency ENUM(IMMEDIATE, DAILY_DIGEST, WEEKLY_DIGEST),
                      updated_at TIMESTAMP,
                      PRIMARY KEY (user_id, category_id, channel))

UserDNDSettings(user_id UUID,
                dnd_enabled BOOL,
                dnd_start TIME,        -- e.g. '22:00'
                dnd_end TIME,          -- e.g. '08:00'
                timezone VARCHAR,      -- 'America/New_York'
                channels_affected VARCHAR[])  -- ['PUSH', 'SMS']

GlobalUnsubscribe(user_id UUID, channel ENUM(EMAIL, SMS),
                  unsubscribed_at TIMESTAMP, reason VARCHAR)

Preference Lookup (Hot Path)

Every notification send — potentially millions per minute — must check preferences. Cache user preferences in Redis:

def should_send(user_id, category, channel):
    # 1. Check global unsubscribe first
    if is_globally_unsubscribed(user_id, channel):
        return False, 'GLOBAL_UNSUBSCRIBE'

    # 2. Load preferences (Redis cache, TTL=1h, DB fallback)
    prefs = get_prefs_cached(user_id)
    pref = prefs.get((category, channel))
    if pref is None:
        # Not set — use category default
        default = get_category_default(category, channel)
        return default, 'DEFAULT'
    if not pref.enabled:
        return False, 'USER_DISABLED'

    # 3. Check DND
    if channel in ('PUSH', 'SMS'):
        dnd = get_dnd_settings(user_id)
        if dnd.enabled and is_dnd_active(dnd):
            return False, 'DND'

    return True, pref.frequency

def get_prefs_cached(user_id):
    key = f'notif_prefs:{user_id}'
    cached = redis.get(key)
    if cached:
        return json.loads(cached)
    prefs = db.query('SELECT * FROM UserNotificationPrefs WHERE user_id=?', user_id)
    redis.setex(key, 3600, json.dumps({(p.category, p.channel): p for p in prefs}))
    return prefs

Digest Aggregation

Users who choose DAILY_DIGEST should receive one email per day, not individual emails. Aggregation:

DigestItem(item_id UUID, user_id UUID, category VARCHAR, channel VARCHAR,
           notification_id UUID, queued_at TIMESTAMP, sent BOOL)

When frequency=DAILY_DIGEST: instead of sending the notification, insert a DigestItem. A nightly cron job (e.g., 8am in user’s timezone) fetches all unsent DigestItems for the user, renders a digest email with all accumulated notifications, sends it, marks items sent=True. Uses user’s timezone from DND settings.

Preference Change Propagation

When a user updates preferences: write to DB, then invalidate the Redis cache key (delete, not update). The next preference lookup rebuilds the cache from DB. Do not update Redis directly — stale cache and DB divergence cause hard-to-debug bugs. For DND changes, also invalidate the DND cache key separately.

Key Design Decisions

  • Redis cache for preferences — every notification send is a read; cache keeps DB load manageable
  • Sparse storage — only store explicitly set preferences; missing = use category default. Avoids inserting rows for every user × category × channel combination at signup
  • Global unsubscribe is a separate table — CAN-SPAM compliance requires immediate honor of unsubscribes; checked before any other logic
  • DND checks timezone-aware time — compare current UTC against user’s local DND window
  • Digest queue vs. send-time aggregation — queue digest items at event time, aggregate at send time to group everything in one email

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you store notification preferences efficiently at scale?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use sparse storage: only store rows for preferences that differ from the category default. At signup, don’t insert a row for every user × category × channel combination — that’s millions of rows. Store only explicit overrides. On lookup, a missing row means "use default." Cache the sparse preference map in Redis (TTL=1h) so the hot path (every notification send) never hits the DB.”}},{“@type”:”Question”,”name”:”How do you implement Do Not Disturb (DND) for notifications?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store DND settings per user: dnd_start (e.g., 22:00), dnd_end (08:00), timezone (America/New_York), and channels_affected (PUSH, SMS). On every notification send, convert the current UTC time to the user’s local time using their stored timezone, then check if it falls within the DND window. DND only applies to interruptive channels (push, SMS) — not email or in-app.”}},{“@type”:”Question”,”name”:”How do you implement a daily digest instead of immediate notifications?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”When frequency=DAILY_DIGEST, instead of sending the notification immediately, insert a DigestItem row with the notification data. A nightly cron job (at the user’s preferred time, e.g., 8am in their timezone) queries all unsent DigestItems for that user, renders a single digest email grouping all accumulated notifications, sends it, and marks items as sent. This batches potentially hundreds of notifications into one email.”}},{“@type”:”Question”,”name”:”How do you handle global unsubscribes (CAN-SPAM compliance)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store global unsubscribes in a separate GlobalUnsubscribe table with (user_id, channel). Check this table first, before any other preference logic — CAN-SPAM requires honoring unsubscribes within 10 business days but best practice is immediate. Some notification categories (security alerts, transactional) may be exempt from global unsubscribe; mark these as can_disable=false in the NotificationCategory table.”}},{“@type”:”Question”,”name”:”How do you propagate preference changes without cache staleness?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Write preference changes to DB first, then delete (not update) the Redis cache key. On the next notification send, the cache miss triggers a fresh DB read that rebuilds the cache. Never write directly to Redis from preference updates — DB is the source of truth. Cache-aside with delete-on-write is simpler and safer than cache-on-write because it avoids race conditions between concurrent updates.”}}]}

Notification preferences and digest systems are discussed in LinkedIn system design interview questions.

Large-scale notification infrastructure is covered in Meta system design interview preparation.

Notification systems and preference management appear in Amazon system design interview guide.

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

Scroll to Top