Design a notification system
Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.
Problem statement
A shared service other teams call to send push, email and SMS notifications: templates, user preferences, retries, deduplication and rate limits.
Requirements
We are building a shared notification service that product teams call to send push, email and SMS, and its hardest caller: "someone you follow just posted", which fans one event out to every follower.
Functional requirements
- Producers submit an event (
post.created,order.shipped,password.reset) or a direct send to one user; the service owns templates, channel choice and delivery. - Templates are versioned per event type, channel and locale, rendered with event variables.
- Users set preferences per category and channel (mute "new posts" push, keep "security" email), plus quiet hours in their timezone.
- Follower fanout: one post event becomes a notification for each eligible follower, including authors with tens of millions of followers.
- Retries with backoff per channel; dedup so a producer retry or a worker crash never sends twice.
- Rate limits: per producer, per recipient (fatigue caps), and per provider (APNs, FCM, SES, Twilio quotas).
- An in-app inbox that clients read to recover anything push dropped.
Non-functional requirements
- Accepting an event is durable and fast: 202 within 50 ms p99, never lost after that.
- Transactional sends (OTP, password reset) reach the provider in under 5 s p99; social fanout may take minutes.
- At-least-once internally, effectively-once to the user via idempotency keys.
- A slow or down provider must not block other channels or other producers.
- Multi-region; a user's data and sends live in their home region.
Back-of-the-envelope estimation
- 500M users, 1.5B registered devices. 2B notification intents/day ≈ 23K/s average, 200K/s peak.
- Post events: 50M posts/day, average 200 followers → 10B candidate recipients/day before eligibility; ~80% are filtered out (muted, inactive, capped), leaving ~2B intents.
- A hot author with 50M followers posting once produces 50M candidates. At 1M follower reads/s that is 50 s of expansion work, so it must be chunked and paced.
- Intent row ≈ 300 B → 2B × 300 B ≈ 600 GB/day; keep 30 days hot ≈ 18 TB in the inbox store.
- Channel mix after aggregation: ~70% push, 25% in-app only, 4% email, 1% SMS (SMS ≈ 20M/day, the expensive one, ~$0.008 each).
API design
Producer ingress (authenticated per producer service, idempotent):
POST /v1/events
{ "idempotency_key": "post:9f2c", "type": "post.created", "actor_id": "u_42",
"audience": {"kind": "followers", "of": "u_42"}, "data": {"post_id": "p_9f2c", "snippet": "..."},
"priority": "social" }
→ 202 { "event_id": "ev_..." }
POST /v1/sends // direct, one recipient, transactional
{ "idempotency_key": "otp:u_7:1739", "user_id": "u_7", "template": "otp",
"channels": ["sms"], "data": {"code": "481920"}, "priority": "critical", "ttl_s": 300 }
→ 202 { "intent_id": "ni_..." }
Device and preference surfaces (called by clients):
PUT /v1/devices/{device_id} { "platform": "ios", "token": "...", "app_version": "8.2", "locale": "en-GB", "tz": "Europe/London" }
DELETE /v1/devices/{device_id}
GET /v1/preferences → { "categories": {"new_posts": {"push": true, "email": false}}, "quiet_hours": {"start": "22:00", "end": "07:00"} }
PATCH /v1/preferences { "categories": {"new_posts": {"push": false}} }
Recovery read (the source of truth when push is lost):
GET /v1/inbox?cursor=...&limit=50 → { "items": [{ "intent_id", "type", "summary", "created_at", "read": false }], "next_cursor", "unread_count" }
POST /v1/inbox/read { "up_to": "ni_..." }
Internal operations: POST /admin/templates (new version, staged rollout), POST /admin/killswitch {category|provider|producer}, GET /v1/intents/{id} (delivery status trail for support).
Data model
| Table | Store | Key / partition | Notes |
|---|---|---|---|
events |
Kafka topic + Postgres outbox | partition by event_id
|
7-day retention; idempotency_key unique per producer for 24 h |
intents (inbox) |
Cassandra / Bigtable | PK (user_id), clustering intent_id (time-ordered, desc) |
The authoritative record; TTL 30 days |
intent_dedup |
Redis |
dd:{user}:{dedup_key} with TTL |
Collapse keys like post:p_9f2c
|
deliveries |
Cassandra | PK (intent_id), clustering (channel, attempt)
|
Status: queued, sent, failed, dropped, suppressed |
devices |
MySQL / Spanner sharded by user_id
|
PK device_id, index (user_id), unique (token)
|
last_seen_at, invalid_at
|
preferences |
same, sharded by user_id
|
PK user_id, JSON per category |
Cached in Redis, 5 min TTL plus invalidation |
templates |
Postgres (small) | PK (type, channel, locale, version)
|
Read-through in-process cache |
follow_index |
owned by the social graph service |
followers:{author} paged by follower_id
|
Read in chunks, never copied |
Boundaries and invariants:
- The service is authoritative for intents, deliveries, devices and preferences. It does not own the follow graph or post content; it reads them.
- An intent is written once per
(user, dedup_key). Delivery records hang off it and are append-only. Lifecycle:pending → aggregated → dispatched → (delivered | failed | expired | suppressed). - The inbox is the durable promise; push, email and SMS are best-effort projections of it.
- Access paths: inbox reads by
user_idnewest-first; device lookup byuser_id; token invalidation bytoken(hence the unique index).
High-level design
flowchart LR
P[Producers] --> API[Ingress API]
API --> K1[(Event log)]
K1 --> FO[Fanout planner]
FO --> G[(Follow graph)]
FO --> K2[(Recipient chunks)]
K2 --> EL[Eligibility workers]
EL --> PR[(Prefs and devices)]
EL --> IN[(Inbox store)]
EL --> K3[(Intent queue)]
K3 --> AG[Aggregator]
AG --> PQ[(Per channel queues)]
PQ --> W[Provider workers]
W --> APNS[APNs and FCM]
W --> EM[Email and SMS providers]
W -. invalid token .-> PR
C[Client app] --> IN
Two layers: first record what the user should know (authoritative, durable), then try to tell them through channels (best effort, retried, droppable).
-
Accept. Ingress checks producer auth and quota, checks
idempotency_keyin Redis (SET NX, 24 h), appends to the event log and returns 202. Direct sends skip fanout and go straight to eligibility on a high-priority partition. -
Expand. The fanout planner looks up the author's follower count. Under 10K followers it pages the follow graph itself and emits chunks of 1,000 recipient IDs. Above that it emits a job descriptor (
author, cursor ranges) onto a separate hot-author lane, and a pool of workers expands ranges at a capped rate. -
Eligibility. For each chunk, a worker batch-reads preferences and devices (one multi-get per 1,000 users), drops muted, blocked, inactive (no open in 90 days) and capped users, then writes an intent row per survivor to the inbox with
dedup_key = post:{post_id}. Duplicate writes are no-ops because of the dedup check plus a deterministicintent_id = hash(user, dedup_key). - Aggregate. Intents for social categories go through a per-recipient aggregator that holds them for a short window and merges bursts ("Ana and 4 others posted"). Critical intents bypass it.
- Deliver. Provider workers pull from per-channel, per-provider queues, render the template for the device locale, call the provider, and record the result. Invalid-token responses mark the device dead.
-
Recover. On open, the client calls
GET /v1/inboxand shows the badge fromunread_count, so a lost or suppressed push costs nothing but latency.
Deep dives
Hot-author fanout and fairness. A 50M-follower post must not starve the queue for everyone else, and must not hammer the follow graph.
| Follower count | Path | Pacing |
|---|---|---|
| < 10K | Inline expansion in the planner | None; done in under a second |
| 10K – 1M | Chunked on the shared lane | Chunks interleave with other events |
| > 1M | Hot-author lane, own worker pool | Cap 200K recipients/s per author; ordered by follower engagement |
Order the big expansions so the most engaged followers (recent opens, close ties) go first: the first 5% of the fanout gets most of the opens, and the long tail can wait minutes or be dropped to inbox only. Track per-author lag; if it exceeds 15 minutes, switch the remaining followers to inbox-only (no push), since a stale "X just posted" push is worse than none. Weighted fair queuing across producers keeps a marketing blast from delaying password resets: critical, transactional and social traffic use separate Kafka topics and separate worker pools.
Recipient burst coalescing. A user who follows 300 active accounts can get dozens of intents an hour. The aggregator keeps per-user state in Redis: agg:{user}:{category} holds pending intent IDs and a deadline. The first intent opens a 2-minute window (10 minutes for low-priority categories); later ones join it. At the deadline it emits one push: a single item if the window holds one, otherwise a summary with a collapse key so the device replaces rather than stacks. Fatigue cap: at most 6 social pushes per user per day and 1 per 30 minutes, kept as a sliding-window counter. Over the cap, the intent stays in the inbox with no push. Quiet hours and timezone-local batching use the same machinery: an intent created at 02:00 local is parked until 07:00 local and merged into a morning digest, with jitter over 30 minutes so a whole timezone does not hit APNs at once.
Retries, idempotency and provider limits. Each delivery attempt carries delivery_id = hash(intent_id, channel, device_id), sent as the provider's idempotency or collapse ID where it has one (APNs apns-collapse-id, email Message-ID). Retry policy depends on the error:
| Error | Action |
|---|---|
| 429 / 503 from provider | Exponential backoff with jitter, 1 s → 5 min, max 6 tries; slow the whole provider queue |
410 Unregistered (APNs), UNREGISTERED (FCM) |
No retry; mark token invalid |
| 400 bad payload | No retry; alert template owner |
| Timeout | Retry once; dedup protects against double send |
Past ttl_s
|
Drop as expired, record it |
Each provider has a token bucket sized to its quota (for example SES 500/s per account, Twilio per-number throughput), shared across workers through Redis with a local allowance per worker. A circuit breaker opens when the error rate passes 20% over 30 s; while open, push falls back to inbox only and critical SMS fails over to a second SMS vendor. Kill switches per category, producer or provider are flags read every few seconds, for a runaway template or a buggy producer.
Token lifecycle. Tokens die quietly: uninstalls, reinstalls, OS restores. Rules: on app start the client always re-registers its token (upsert keyed by device_id, unique on token so a token that moved to a new install detaches from the old device); provider invalid responses set invalid_at and stop sends at once; devices unseen for 270 days are pruned; on logout the device is deleted so a shared phone does not get the previous user's DMs. Feedback-driven cleanup keeps invalid-token rates under 1%, which matters because providers throttle senders with high failure rates.
Regional routing. Each user has a home region that holds their inbox, devices and preferences. A post in region A whose followers live in B and C is fanned out by the planner in A into per-region chunk streams; each region's eligibility workers write to their local stores. Only the event and recipient ID lists cross regions, never device tokens, which also helps with data residency.
Trade-offs
- Writing the inbox before sending gives a recoverable source of truth at the cost of ~2B writes a day; sending straight from the queue is cheaper but loses anything a provider drops.
- Aggregation windows reduce noise and provider cost, but add up to 2–10 minutes of latency to social pushes; critical traffic opts out.
- Engagement-ordered hot-author fanout delivers value fast, but the last followers can be minutes late or downgraded to inbox only.
- At-least-once plus dedup keys is simpler and more robust than exactly-once machinery, but needs a dedup store with TTL and still allows a rare double send if a provider times out after accepting.
- Reading preferences at eligibility time (not at send) means a mute takes effect for new intents only; already-queued pushes in the next couple of minutes still go out unless the worker rechecks a cached flag.
- Per-region homing keeps tokens local and latency low but makes cross-region fanout two hops and complicates users who move.
Interviewer expectations
Mid-level (L4)
- Queue between ingress and senders, per-channel workers, templates and a preferences check.
- Retries with backoff and an idempotency key on the API.
- Reasonable tables for devices, preferences and notification history.
Senior (L5)
- Separates durable intent (inbox) from best-effort delivery and uses the inbox for client recovery.
- Handles hot-author fanout with chunking and a separate lane; isolates critical from bulk traffic.
- Token invalidation from provider feedback, provider rate limits, fatigue caps and quiet hours.
Staff (L6+)
- Explicit lifecycle and invariants (one intent per user and dedup key, append-only deliveries) and who owns which data.
- Fairness and degradation policy: engagement ordering, lag-triggered downgrade to inbox only, circuit breakers, kill switches, SMS failover.
- Regional homing, cost of SMS and email, and how to measure success (open rate, opt-out rate, end-to-end lag per priority).