InterviewKata
System design · Worked design D05 of 46 · Warm-up · Long form

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

  1. A service can send a notification to a user by type and data; the system picks channels, renders the template and delivers it.
  2. The system respects each user's preferences: opt-outs, quiet hours, frequency caps.
  3. 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:

4. How well it must do it

TargetWhy this number
Urgent is fast: a login code reaches the provider within 5 s at p99, whatever else is happening speed · load shapeA 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 · consistencyDuplicates annoy people; lost codes lock them out.
Scale: 100M a day, campaigns of 20M an hour load shapeMarketing sends in bursts.
Provider-proof: survives a provider outage and never exceeds a provider's rate limit surviving failuresExceeding limits gets the account throttled or blocked for everyone.
Compliant: unsubscribes take effect immediately; consent is recorded rulesEmail and SMS rules (anti-spam laws, consent) carry real penalties.

5. Numbers that change the design

QuantityWorking→ so we…
Average rate100M ÷ 10⁵ s~1,000/s: modest
Campaign burst20M in 3,600 s~5,500/s on top → so queues absorb bursts, and workers scale on queue depth
A login code behind a campaign600,000 queued messages ÷ 2,000/s5 minutes of waiting → so priorities need separate lanes (try the lab)
Status records100M a day × ~300 bytes, kept 90 days~3 TB → so a write-heavy wide-column store, partitioned by user
Device tokens200M users × ~2 devices400M tokens looked up by user: a key-value or relational table

6. The nouns

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)

  1. One queue: a 600,000-message campaign puts every login code five minutes behind it.
  2. Retries on timeouts send duplicates; a worker crash after sending sends again.
  3. A provider outage or rate limit stops everything.
  4. Quiet hours, time zones and caps aren't handled.
  5. A 20M-user loop in one API call can't resume after a crash.
  6. 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

ApproachWhy
✗ BadOne queue for everythingA 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).
~ GoodA priority field in one queue; workers pick the highest firstBetter, but most queues don't reorder cheaply, and campaign floods still compete for workers and provider quota.
✓ GreatSeparate queues and worker pools per priority (transactional, standard, bulk), with provider rate limits reserved for transactional trafficA 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

ApproachWhy
✗ BadTrust that callers send each event once and workers never retryCallers retry on timeouts; queues deliver at least once; workers crash after sending. Users get two or three copies.
~ GoodAn idempotency key on the API: a repeat with the same key returns the original notificationStops duplicate requests, but not a worker that sends, crashes and is retried.
✓ GreatIdempotency 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 itDuplicates 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

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):

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

FailureWhat users seeHow we detect itWhat limits the damage
Provider outageDelayed messages on one channelProvider error rate; breaker stateFailover to a second provider; bulk waits
Rate-limited by a providerSlower campaigns429s from the providerToken buckets per provider; reserved transactional quota
Worker crash after sendingPossibly one duplicateStale sending recordsDelivery records; reconcile with provider message ids
Campaign job crashes halfwayNothingJob heartbeat lostResume from the last completed batch
Bad campaign contentWrong message to some usersComplaints, error rates on the first 1%Progressive rollout, auto-pause, kill switch
Preference cache staleA message after unsubscribingComplaints, auditsChange-event invalidation; short TTLs

12. Wrap-up and the clock

Trade-offs we made:

What we'd watch:

At 10×:

A 45-minute interview

0–4Frame the problem (channels, priorities, duplicates vs losses)
4–9Must do, how well, numbers
9–13Nouns and contract
13–21First working version, parked list
21–40Deep dives: priorities (9.1), dedup (9.2), providers (9.3), campaigns (9.5)
40–45Follow-ups and wrap-up

A 60-minute interview

0–5Frame the problem
5–11Must do, how well, numbers
11–16Nouns and contract
16–25First working version
25–50Deep dives 9.1–9.6 and the final picture
50–60Follow-ups: grouping, ordering, bad campaigns; wrap-up
LevelWhat a strong answer shows
Mid-levelAPI → queue → channel workers → providers, templates, a preferences check, retries.
SeniorPriority 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:

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.

600,000
2,000/s

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 bulkhead: each priority class has its own lane and reserved provider quota.

A worker sends an email, then crashes before acknowledging the message. What prevents a duplicate on retry?

The retry sees the notification was already sent (or is being reconciled) and skips it.

How should a 20-million-user campaign be enqueued?

Batches spread the work, respect quotas, and survive crashes.

For login codes, which error is better to risk?

Exactly-once to external providers isn't possible; choose per type.
ResourceWhy
Sending notification requests to APNs (Apple) and Firebase Cloud MessagingHow the push providers actually work: tokens, priorities, errors.
Design a notification system (Hello Interview)Another walkthrough.