Design a notification system
Every product eventually needs to tell users things: a login code, "your order has shipped", a reminder, a weekly digest. A notification service takes those requests from many internal teams and delivers them through push, email, SMS and an in-app inbox, at the right time, once from the user's point of view, without spamming anyone, and without a marketing campaign delaying someone's password-reset code. It follows the interview framework phase by phase.
1. The prompt
"Design a system that sends notifications to users across channels. Internal services (orders, security, marketing) call it; it decides how and when to reach each user."
The hard parts are not the sending itself, which external providers do, but everything around it: separating urgent from bulk traffic, never sending twice, surviving providers that fail and throttle, respecting each user's preferences and time zone, and pushing a 20-million-person campaign without hurting anything else.
2. Frame the problem
Which channels?
Push (iOS and Android), email, SMS and an in-app inbox.
Each channel has its own provider, limits and failure modes, so each gets its own worker pool.
What kinds of messages, and how urgent?
Transactional (login codes, receipts), product (reminders, social activity) and marketing campaigns.
Priorities must be isolated: a campaign must never delay a login code.
How many?
About 100M a day, with campaigns of up to 20M in an hour.
Bursts six times the average: queues and worker scaling.
Do users control what they get?
Yes: opt-outs per channel and type, quiet hours, and limits on marketing frequency.
A preference check on every message, so preferences must be fast to read.
Is a duplicate worse than a missing message?
Depends on the type: a missing login code is worse than a duplicate; a duplicate marketing push is just annoying.
We choose which way to err per type, because true exactly-once to an external provider isn't possible.
Do senders need delivery status?
Yes: sent, delivered, opened, failed.
Status arrives asynchronously from providers, so we need an event pipeline.
Templates and languages?
Yes, managed by teams; messages are rendered per locale.
Rendering happens in the pipeline, not in the callers.
3. What it must do
- A service can send a notification to a user by type and data; the system picks channels, renders the template and delivers it.
- The system respects each user's preferences: opt-outs, quiet hours, frequency caps.
- A team can send a campaign to a large audience, optionally at a local time in each user's time zone, and see delivery status.
Not now:
- The audience-segmentation tool: we consume its output.
- The template editor UI: standard tooling.
- Two-way messaging: replies, chatbots.
4. How well it must do it
| Target | Why this number |
|---|---|
| Urgent is fast: a login code reaches the provider within 5 s at p99, whatever else is happening speed · load shape | A user waiting for a code is staring at the screen. |
| Once, not lost: duplicates rare (well under 0.1%), and nothing disappears silently data safety · consistency | Duplicates annoy people; lost codes lock them out. |
| Scale: 100M a day, campaigns of 20M an hour load shape | Marketing sends in bursts. |
| Provider-proof: survives a provider outage and never exceeds a provider's rate limit surviving failures | Exceeding limits gets the account throttled or blocked for everyone. |
| Compliant: unsubscribes take effect immediately; consent is recorded rules | Email and SMS rules (anti-spam laws, consent) carry real penalties. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Average rate | 100M ÷ 10⁵ s | ~1,000/s: modest |
| Campaign burst | 20M in 3,600 s | ~5,500/s on top → so queues absorb bursts, and workers scale on queue depth |
| A login code behind a campaign | 600,000 queued messages ÷ 2,000/s | 5 minutes of waiting → so priorities need separate lanes (try the lab) |
| Status records | 100M a day × ~300 bytes, kept 90 days | ~3 TB → so a write-heavy wide-column store, partitioned by user |
| Device tokens | 200M users × ~2 devices | 400M tokens looked up by user: a key-value or relational table |
6. The nouns
- Notification: one message to one user.
- Delivery: one notification on one channel, with its status.
- Preference.
- Device: a push token.
- Template.
- Campaign: a batch of notifications to a segment.
notifications notification_id, user_id, type, priority, data, idempotency_key, created_at deliveries (notification_id, channel) → status (pending | sending | sent | delivered | failed), provider_msg_id preferences user_id → per (channel, type) opted_in, quiet_hours, time_zone, caps devices user_id → [platform, push_token, last_seen] campaigns campaign_id, segment, template, send_at_local, progress (last batch done)
7. The contract
REST for internal callers, with the calling service identified by its credentials. An idempotency key lets callers retry safely.
POST /v1/notifications Idempotency-Key: order-881-shipped
{ "user_id": 42, "type": "order_shipped", "priority": "transactional",
"data": {"order_id": 881, "eta": "Friday"} }
→ 202 Accepted {notification_id}
POST /v1/campaigns {segment_id, type, template, send_at_local: "09:00"} → 202 {campaign_id}
GET /v1/notifications/{id} → {status per channel}
PUT /v1/users/{id}/preferences (from the user's own settings page; user from their session)
Data flow: (1) accept and dedupe → (2) check preferences and choose channels → (3) render the template → (4) enqueue per channel and priority → (5) a channel worker calls the provider → (6) status events come back and update the delivery.
8. First working version
An API service, a database, one queue, and one worker pool that sends through each provider.
8.1 Sending one notification
The API stores the notification, checks preferences, renders the template for each allowed channel, and enqueues one message per channel. Workers take messages from the queue, call the provider, and record the result.
8.2 Sending a campaign
The campaign API loops over the segment and calls the same path for each user.
8.3 Showing status
Workers write "sent" to the deliveries table; provider webhooks update it to "delivered" or "failed".
Parked for later (section 9 fixes each)
- One queue: a 600,000-message campaign puts every login code five minutes behind it.
- Retries on timeouts send duplicates; a worker crash after sending sends again.
- A provider outage or rate limit stops everything.
- Quiet hours, time zones and caps aren't handled.
- A 20M-user loop in one API call can't resume after a crash.
- Status updates hammer the deliveries table, and analytics queries it directly.
9. Make it hold up
9.1 Login codes must not wait behind a campaign urgent is fast
| Approach | Why | |
|---|---|---|
| ✗ Bad | One queue for everything | A 20M-message campaign queued at 9:00 puts every login code behind it: users can't sign in for an hour (try it in section 13). |
| ~ Good | A priority field in one queue; workers pick the highest first | Better, but most queues don't reorder cheaply, and campaign floods still compete for workers and provider quota. |
| ✓ Great | Separate queues and worker pools per priority (transactional, standard, bulk), with provider rate limits reserved for transactional traffic | A bulkhead (Module 24): campaigns can't starve critical messages, and each class scales on its own queue depth. |
9.2 Once, from the user's point of view once, not lost
| Approach | Why | |
|---|---|---|
| ✗ Bad | Trust that callers send each event once and workers never retry | Callers retry on timeouts; queues deliver at least once; workers crash after sending. Users get two or three copies. |
| ~ Good | An idempotency key on the API: a repeat with the same key returns the original notification | Stops duplicate requests, but not a worker that sends, crashes and is retried. |
| ✓ Great | Idempotency on the API, plus a per-(notification, channel) delivery record: mark sending before calling the provider and sent after. A retry that finds sent skips; one that finds a stale sending is reconciled with the provider's message id where providers support it | Duplicates become rare edge cases instead of a pattern. True exactly-once to an external provider isn't possible, so choose which way to err: for login codes, a rare duplicate beats a missing code. |
9.3 Providers fail and throttle provider-proof
- Each channel worker wraps its provider (APNs for iOS, Firebase Cloud Messaging for Android and web, an email service, an SMS gateway) with timeouts, retries with backoff and jitter, and a circuit breaker.
- Keep a second provider for SMS and email, and fail over when the first's breaker opens.
- Respect provider rate limits with a token bucket per provider and account (Design D02), so a campaign slows down instead of getting the account blocked.
- Messages that fail permanently (invalid number, uninstalled app) aren't retried: they update the device or contact record. Expired push tokens are removed.
9.4 Preferences, quiet hours and frequency caps compliant · once, not lost
Before sending, check the user's preferences (cached, since they're read for every message):
- Opt-outs and unsubscribes: unsubscribing is legally required for marketing email.
- Quiet hours in the user's time zone: delay non-urgent messages until morning; never delay login codes.
- Frequency caps, such as "at most 3 marketing pushes a week", counted in a fast store.
- Active users: if the user is active in the app right now, an in-app message may replace the push.
When a user unsubscribes, the change event invalidates the cached preferences at once, so the next message respects it.
9.5 A campaign to 20 million users scale
Don't enqueue 20 million messages in one API call. A campaign job reads the segment in pages, creates batches (say 1,000 users each) as jobs on the bulk queue, and records progress, so a crash resumes where it stopped. For "9:00 in each user's time zone", group users by time zone and schedule each group's batches separately (a scheduler like Design D21 does this well). Throttle to the provider quota left over after transactional traffic.
9.6 Tracking delivery once, not lost
Statuses arrive asynchronously: provider responses ("accepted"), delivery receipts and webhooks ("delivered", "bounced"), and opens and clicks from tracking links or the app. Write them as events to a log, and update a per-delivery status record. Analytics (open rates per campaign) comes from the event stream, not from querying the status table. The in-app inbox is a per-user list in a database, read when the app opens, plus a real-time nudge over WebSocket or SSE if the app is open (Module 19).
9.7 The whole design after the deep dives
A login code and a campaign at the same moment, through the final design.
10. Interviewer follow-ups
Traffic grows 10×.
Partition queues by user id (which also keeps per-user order), shard the status store, cache preferences locally on workers with change-event invalidation, and add provider accounts. Watch provider quotas: they often run out before your servers do.
The SMS provider has an outage.
The breaker opens; transactional SMS fails over to the second provider; bulk SMS waits in its queue. For login codes, the product can also offer another channel (email or an authenticator app).
Group notifications: "Asha and 12 others liked your photo".
Delay social notifications for a short window and aggregate them per (user, type, object). A digest job sends one message at the end of the window and updates it in the inbox if more arrive.
Users complain about too many notifications.
Global frequency caps across all teams, per-type budgets, digests, and ranking: when the budget is used up, send only the most valuable messages. Measure opt-out rates per type as a health signal.
A team sends a campaign with a broken link to 20M people.
Campaigns roll out progressively (1%, then 10%, then the rest), with automatic pause on high error or complaint rates, and a kill switch. Approval for very large sends.
Ordering: "order shipped" must not arrive after "order delivered".
Partition the queue by user (or order) id so one worker handles a user's messages in order, and include a sequence number so a late message can be dropped if a newer one was sent.
Multi-region?
Run the pipeline per region, with users homed in a region. Preferences replicate globally. Providers are global, so each region has its own provider accounts and limits.
Build or buy?
Managed services (Firebase, SNS-style services, Braze, OneSignal, Twilio, SendGrid) cover much of the delivery. A platform team still owns the routing, preferences, dedup and priority layer, because that's where product rules live.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Provider outage | Delayed messages on one channel | Provider error rate; breaker state | Failover to a second provider; bulk waits |
| Rate-limited by a provider | Slower campaigns | 429s from the provider | Token buckets per provider; reserved transactional quota |
| Worker crash after sending | Possibly one duplicate | Stale sending records | Delivery records; reconcile with provider message ids |
| Campaign job crashes halfway | Nothing | Job heartbeat lost | Resume from the last completed batch |
| Bad campaign content | Wrong message to some users | Complaints, error rates on the first 1% | Progressive rollout, auto-pause, kill switch |
| Preference cache stale | A message after unsubscribing | Complaints, audits | Change-event invalidation; short TTLs |
12. Wrap-up and the clock
Trade-offs we made:
- Separate lanes per priority (more infrastructure, guaranteed latency for urgent messages).
- Delivery records for dedup (an extra write per send).
- Erring towards duplicates for login codes, and towards losses for marketing.
- Batched, resumable campaigns.
- Status from an event stream.
What we'd watch:
- Transactional time-to-provider p99.
- Queue depth per lane.
- Provider errors and 429s.
- Duplicate rate.
- Delivery and opt-out rates per type.
At 10×:
- Partitioned queues.
- More provider accounts.
- Local preference caches.
A 45-minute interview
| 0–4 | Frame the problem (channels, priorities, duplicates vs losses) |
| 4–9 | Must do, how well, numbers |
| 9–13 | Nouns and contract |
| 13–21 | First working version, parked list |
| 21–40 | Deep dives: priorities (9.1), dedup (9.2), providers (9.3), campaigns (9.5) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–11 | Must do, how well, numbers |
| 11–16 | Nouns and contract |
| 16–25 | First working version |
| 25–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: grouping, ordering, bad campaigns; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | API → queue → channel workers → providers, templates, a preferences check, retries. |
| Senior | Priority isolation, idempotency and delivery records, provider failover and rate limits, campaign batching, status via events. |
| Staff+ | Time-zone scheduling, frequency caps and ranking, deliverability (bounces, invalid tokens), compliance (unsubscribe, consent), progressive campaigns, and erring towards duplicates or losses per type. |
Common mistakes:
- One queue.
- Claiming exactly-once delivery.
- No plan for provider limits.
- Sending 20M messages from one request.
- Ignoring quiet hours and unsubscribes.
- Querying the status table for analytics.
13. Try it, check yourself, read more
At 9:00 a campaign of 600,000 messages is queued. Login codes keep arriving at 50 a second. Workers can send 2,000 messages a second in total. Compare one shared queue with separate queues.
Model: with one queue, messages are sent in arrival order. With separate queues, transactional messages get a reserved 10% of the capacity (and any unused share goes to the campaign).
How do you stop a marketing campaign delaying login codes?
A worker sends an email, then crashes before acknowledging the message. What prevents a duplicate on retry?
How should a 20-million-user campaign be enqueued?
For login codes, which error is better to risk?
| Resource | Why |
|---|---|
| Sending notification requests to APNs (Apple) and Firebase Cloud Messaging | How the push providers actually work: tokens, priorities, errors. |
| Design a notification system (Hello Interview) | Another walkthrough. |