Design notification preferences with topic subscriptions, quiet hours, and frequency caps
Users opt-in/out per topic per channel, set quiet hours (timezone-aware), and the system enforces frequency caps (max 5 marketing notifications/day). All preference checks happen BEFORE sending — on the critical path of every notification. With 500M users and 50 preference entries each, how do you check 25B preference records in <1ms per notification?
What the preference and frequency cap system must do
Quality constraints for the preference checking layer
| Property | Target | Why It Matters / Design Impact |
|---|---|---|
| Check Latency | <1ms per notification | On the critical send path. At 58K notifications/sec, even 5ms adds 290 worker-seconds/sec. Must be sub-millisecond. |
| Opt-out Propagation | <5s from user action | GDPR/CAN-SPAM compliance. User clicks unsubscribe → no more notifications within 5s. Pub/sub cache invalidation. |
| False Positive Rate | <0.1% Bloom filter | Bloom filter may say "possibly subscribed" when actually unsubscribed (false positive → falls through to Redis). Never says "unsubscribed" when actually subscribed (no false negatives). |
| Storage Efficiency | 25B records in-memory | Can't store all 25B preferences in Redis. Bloom filter compresses to ~3GB for 500M users. Redis stores only active/hot preferences. |
| GDPR Compliance | Immediate effect | Unsubscribe-all must propagate globally. No "we'll stop in 24 hours" — must be immediate. Bloom filter rebuilt async. |
| Timezone Accuracy | Correct to user's offset | Quiet hours must respect DST transitions. Store timezone name (not offset). Calculate at check time. |
Sizing the preference infrastructure for 500M users
| Step | What to Derive | Calculation | Result | Design Decision |
|---|---|---|---|---|
| 1 | Total preference records | 500M users × (10 topics × 4 channels + 1 quiet_hours + 10 freq_caps) | 25.5B records | Can't fit in memory. Need tiered approach: Bloom + Redis + DB. |
| 2 | Bloom filter size (unsubscribes) | ~2B unsubscribed entries × 10 bits/entry (0.1% FPR) | ~2.5GB | Fits in worker memory. Loaded on startup. Rebuilt every 15 min. |
| 3 | Checks/sec hitting Redis (post-Bloom) | 58K × 30% pass-through | ~17.4K Redis lookups/sec | Redis cluster handles 100K+ ops/sec easily. Well within capacity. |
| 4 | Redis memory for hot preferences | 100M active users × 50 entries × 32 bytes | ~160GB | Redis cluster (32 shards). LRU eviction for inactive users. Reload from DB on miss. |
| 5 | Frequency cap counters | 100M active users × 5 categories × 32 bytes | ~16GB | Redis with TTL matching window size. Sliding window via sorted set or INCR+EXPIRE. |
| 6 | Quiet hours check CPU | 58K/sec × timezone calculation (~10μs each) | ~0.58 CPU cores | Negligible. Cache timezone offset per user (recalc on DST change). |
| 7 | Bloom filter rebuild time | 2B entries × 10 hash operations / 1M ops/sec | ~33 minutes | Background rebuild every 15 min (overlapping). Hot-swap on completion. Old filter serves during rebuild. |
Preference management and GDPR compliance endpoints
{
"preferences": [
{ "topic": "promotions", "channel": "push", "enabled": false },
{ "topic": "promotions", "channel": "email", "enabled": true },
{ "topic": "order_updates", "channel": "push", "enabled": true },
{ "topic": "order_updates", "channel": "sms", "enabled": true }
],
"quiet_hours": {
"enabled": true,
"start": "22:00",
"end": "08:00",
"timezone": "America/New_York"
},
"frequency_caps": [
{ "category": "marketing", "max_count": 5, "window": "24h" },
{ "category": "social", "max_count": 20, "window": "24h" }
]
}
200 OK · Propagates to cache within 5s via pub/sub invalidationGET /v1/users/u_abc123/preferences
Response 200:
{
"user_id": "u_abc123",
"preferences": [...],
"quiet_hours": { "enabled": true, "start": "22:00", "end": "08:00", "timezone": "America/New_York" },
"frequency_caps": [...],
"global_unsubscribe": false,
"updated_at": "2024-01-15T10:00:00Z"
}
{
"user_id": "u_abc123",
"reason": "user_requested", // user_requested | gdpr_request | account_deletion
"retain_essential": true // keep security/auth notifications
}
Response 200:
{
"user_id": "u_abc123",
"global_unsubscribe": true,
"essential_only": true,
"effective_at": "2024-01-15T10:00:01Z", // <1s from request
"topics_disabled": 9,
"channels_disabled": 36
}
Preference storage: Bloom filter + Redis + persistent DB
// Per-user per-topic per-channel preference
// Key: preferences:{user_id}:{topic}:{channel}
SET preferences:u_abc123:promotions:push "false"
SET preferences:u_abc123:promotions:email "true"
SET preferences:u_abc123:order_updates:push "true"
// Alternatively, hash per user (fewer keys):
HSET prefs:u_abc123 "promotions:push" "0" "promotions:email" "1" "order_updates:push" "1"
// User's quiet hours configuration
HSET quiet_hours:u_abc123
enabled "true"
start "22:00"
end "08:00"
timezone "America/New_York"
utc_offset "-05:00" // cached, recalculated on DST change
// Sliding window counter using sorted set
// Key: freq_cap:{user_id}:{category}:{window}
// Score = timestamp, Member = notification_id
ZADD freq_cap:u_abc123:marketing:24h 1705312200 "n_001"
ZADD freq_cap:u_abc123:marketing:24h 1705315800 "n_002"
// Check count in window:
ZCOUNT freq_cap:u_abc123:marketing:24h (now - 86400) +inf
// If count >= 5, suppress notification
// Cleanup expired entries:
ZREMRANGEBYSCORE freq_cap:u_abc123:marketing:24h 0 (now - 86400)
// Bloom filter encodes "unsubscribed" entries
// Key format: "{user_id}:{topic}:{channel}"
// Inserted when user unsubscribes
bloom.add("u_abc123:promotions:push") // user unsubscribed from push promotions
// At check time:
if bloom.contains("u_abc123:promotions:push"):
return SUPPRESSED // definitely unsubscribed (no false negatives)
else:
// might be subscribed — check Redis for exact answer
redis.get("preferences:u_abc123:promotions:push")
// Bloom filter properties:
// Size: 2.5GB for 2B entries
// Hash functions: 7 (optimal for 0.1% FPR)
// Rebuild: every 15 min from DB snapshot
Notification arrives → Bloom filter → Redis preferences → Quiet hours → Frequency cap → Allow/Suppress
Tradeoffs for the preference checking layer
| Decision | Options Considered | Chosen | Why |
|---|---|---|---|
| Fast Rejection | Redis-only, local cache, Bloom filter | Bloom filter + Redis fallback | Bloom rejects 70% in ~0.05ms (in-process). Only 30% hit Redis. Reduces Redis load 3.3× and cuts P99 latency significantly. |
| Frequency Cap Algorithm | Fixed window, sliding window (ZSET), sliding window log | Redis Sorted Set sliding window | True sliding window (no edge-of-window spikes). ZCOUNT is O(log N + M) but M ≤ 20 typically. Auto-cleanup via ZREMRANGEBYSCORE. |
| Quiet Hours Timezone | Store UTC offset, store timezone name, client-side check | Store timezone name (IANA) | "America/New_York" handles DST automatically. Offset-based breaks twice a year. Server-side check ensures enforcement. |
| Opt-out Propagation | DB poll, cache TTL, pub/sub invalidation | Redis pub/sub + Bloom rebuild | Pub/sub gives <5s propagation to all workers. Bloom filter can't update atomically — rebuilt every 15 min. Gap covered by Redis lookup on Bloom miss. |
| GDPR Unsubscribe | Soft delete preferences, global flag, delete all records | Global flag + async cleanup | Setting a global flag is instant (1 Redis SET). All preference checks first check the global flag. Async job cleans up individual preferences later. |
Failure modes in the preference checking layer
| Scenario | Problem | Solution | Recovery |
|---|---|---|---|
| Redis Down | Preference cache unavailable — can't check subscriptions. | Fail-open for transactional (still send OTP). Fail-closed for marketing (suppress if can't verify). Circuit breaker on Redis calls. | Graceful degradation |
| Bloom Filter Stale | User unsubscribed 10 min ago, Bloom filter not yet rebuilt. | Bloom filter only fast-rejects. For "maybe subscribed" (pass-through), Redis always has the latest. Stale Bloom = more Redis hits, not incorrect sends. | Correctness maintained (latency degrades) |
| DST Transition | Clock jumps 1 hour. Quiet hours window shifts unexpectedly. | Use IANA timezone names. Recalculate offset at check time (not cached offset). Libraries handle DST automatically. | Automatic via timezone library |
| Frequency Cap Burst | Event triggers 10 notifications simultaneously for same user. All check before any increment. | Atomic Lua script: ZCOUNT + conditional ZADD. Only one notification per Lua execution can pass. Others rejected atomically. | Atomic enforcement |
| New User (Cold Start) | User has no preferences in Redis or Bloom filter. What are defaults? | Default preferences stored per-topic in config (not per-user). If no user preference found, apply topic default. Most topics default to "subscribed". | Config-based defaults |
| Preference Write Storm | Marketing campaign causes 1M users to unsubscribe simultaneously. | Write buffer: batch preference updates into 1s windows. Async propagation to Redis. Bloom filter rebuild triggered on high-write-rate. | <10s eventual propagation |
5 key points for notification preference system design interviews