Case Study: Notification System
Shows queues, fan-out, user preferences, deduplication, and external providers that fail.
- 5.8K/saverage: 500M notifications a day
- 50K+/scampaign burst, so throttle the fan-out
- ~8 min1M campaign draining a shared queue at 2,000/s
Show what to say
“I accept the request with a 202, check preferences, and publish one message per channel onto queues split by channel and priority, so an OTP never waits behind a marketing campaign. Workers dedupe on eventId and channel, then call providers through circuit breakers with failover, and failures end in a dead letter queue with an alert.”
Trade-offs
- Shared queue vs priority lanes: lanes cost more queues and workers, but an OTP leaves in milliseconds instead of waiting behind the campaign.
- At-least-once vs exactly-once: exactly once to a phone is impossible, so dedupe on deterministic ids and aim for effectively once.
- Mark sent early vs after acceptance: marking after the provider accepts allows a rare resend after a crash, but failed sends can still retry.
Bring it up before they ask
A provider that accepts but silently doesn't deliver is worse than one that errors, so track delivery rate per provider.
Accept the request with 202, then put one message per channel on queues split by channel and priority, so a login code never waits behind a marketing blast. each message, and send through providers with .
Why it matters
This system is write-heavy and asynchronous.
Read more · 66 words
One slow channel must not hurt the rest, there must be no duplicates, and you do not control the providers.
Skip all this for a small app: 100K users at 5 messages a day is about 6 sends a second, so one worker and a retry loop do. Play all four scenarios.
Functional
- Send push, email and SMS
- Per-user preferences by channel and type
- Templates with localisation
- Priorities: an OTP never waits behind marketing
- Scheduled sends and quiet hours
- Per-user rate limits and daily caps
- Delivery tracking and analytics
- No duplicates
Non-functional
- OTPs delivered within seconds
- At-least-once delivery; users see each message once
- A provider outage doesn't stop delivery
- Absorb 50K+/s campaign bursts without slowing critical traffic
- No silent loss: failures end in a DLQ, with an alert
Out of scope
- Running our own push, email or SMS delivery
- The campaign editor UI
- A rich in-app inbox
Ask early which types exist and how urgent each is. That drives priority isolation.
Tap any box to see what it is and what it talks to.
- Client
- Queue
- Service
- Database
- Worker
- Cache
- External API
Text description
- The order service emits order_update for user 42 (event 812), with an idempotency key.
- The notification service loads the user's preferences, which are cached in front of the database.
- Push, email and SMS are all enabled for order updates. The type maps to priority high.
- It publishes one message per channel, with deterministic ids 812:push, 812:email and 812:sms, on the high-priority queues.
- Three workers pick up their messages in parallel. Each channel scales and fails on its own, so a slow SMS provider can't delay push.
- Each worker renders the template in the user's language and calls its provider.
- All three providers accept. Workers record sent:<id> and a delivery row; webhooks report delivered and opened later.
Estimate, API and data model
50M users get 10 messages a day: 500M a day, about 5.8K a second on average.
Read more · 303 words
Campaigns burst to 50K+ a second, far above what providers accept. Queues absorb the burst, and you slow the campaign down on purpose.
A worker with 100 provider calls in flight at 100 ms each sends about 1,000 a second. The average needs about 6 workers and a 50K burst would need 50. You do not buy 50 workers for a burst; you throttle it. The log is 500M rows at about 200 bytes, 100 GB a day, so deliveries is partitioned by day.
| Method | Path | Result |
|---|---|---|
| POST | /v1/notifications | 202 { notificationId } |
| PUT | /v1/users/:id/notification-preferences | Channels, types, quiet hours, time zone |
| POST | /webhooks/:provider | Delivery callbacks (delivered, bounced) |
202 Accepted means "queued", not "sent". A retry with the same idempotencyKey returns the same notificationId.
Preferences are read for every notification: them. deliveries is the busiest table: partitioned by day, looked up by provider_msg_id when arrive.
Device tokens rot: phones get wiped, apps get uninstalled.
- Refresh the token on every app start.
- Invalid or unregistered? Delete it now.
- Three devices, three pushes: render once, send to each token.
Delivery tracking. Providers report back by webhook (delivered, bounced, opened). Update the delivery row by provider_msg_id.
- Verify the signature over the raw body first.
- Webhooks retry, so updates must be .
- They arrive out of order: never let a late
deliveredoverwriteopened.
CREATE TABLE notification_prefs (user_id bigint, channel text, type text, enabled boolean NOT NULL,
PRIMARY KEY (user_id, channel, type));
CREATE TABLE device_tokens (token text PRIMARY KEY, user_id bigint NOT NULL, platform text, last_seen timestamptz);
CREATE TABLE deliveries (id text PRIMARY KEY, -- `${eventId}:${channel}`: the dedupe key
user_id bigint, channel text, provider text, provider_msg_id text,
status text, updated_at timestamptz);
CREATE INDEX deliveries_provider_msg ON deliveries (provider_msg_id); -- webhook lookupsQueues and priorities
One event becomes one message per channel, and the queue layout decides what can hurt what.
Read more · 314 words
| Layout | What happens | Use it when |
|---|---|---|
| One queue for everything | One slow provider or one big campaign delays every message. Workers stuck on 2 s SMS calls leave push waiting | A prototype under about 10 messages a second, all equally urgent |
| One queue per channel | A slow provider cannot block the other channels. But an OTP and a promo are both SMS, so the OTP still waits behind the blast | All messages in a channel share one urgency |
| Per channel and priority (default) | Separate queues and separate workers: a campaign can only fill its own lane | Any system that sends both codes and marketing |
| Priority levels in one queue | Some brokers order one queue by priority (RabbitMQ yes; Kafka and standard SQS no). Risk is starvation: low messages may never leave | Low volume, and a broker with priorities. Add dedicated workers for the low lane or age messages upward |
A 1M campaign on one shared queue at 2,000 a second takes 8 minutes, so a login code expires in line. With lanes it leaves in milliseconds. Try Shared queue, then Two lanes.
| Queue | Messages | Workers |
|---|---|---|
notif.sms.critical | OTPs, security alerts | Dedicated, spare capacity |
notif.push.high | Order updates | Scaled on queue depth |
notif.push.low | Marketing, digests | Throttled, paused first |
A separate queue drained by the same busy workers is still a shared bottleneck. Reserve provider capacity: if the provider allows 5,000 SMS a second, cap the low lane at 4,000 and the critical lane keeps 1,000. Keep to two or three lanes: ten lanes are ten things to scale. The cost is idle capacity in the critical lane. A message that keeps failing lands in a , with an alert.
Tap any box to see what it is and what it talks to.
- Service
- Queue
- Worker
- External API
Text description
- A 1M-message promo lands in sms.low.
- Eight low workers chew through it for about 8 minutes.
- A login OTP arrives in the middle of the campaign. Type otp means critical.
- The critical lane is empty and its two workers are idle, so the OTP is picked up in milliseconds.
- The OTP is sent while the campaign is still draining.
Scheduling, users and campaigns
Templates. Copy changes weekly; deploys should not have to.
Read more · 546 words
- Reference a template id, version and locale, never raw text.
- Render in the worker, late, so data and language are fresh. Fall back (
pt-BR, thenpt, thenen). - One variant per channel. SMS fits 160 characters (70 with an emoji).
Scheduling and quiet hours. A scheduled send and a 3 a.m. marketing push need the same fix: a notBefore time and somewhere to park the message until then.
- Store an IANA time zone per user (
Asia/Kolkata, not+05:30, which breaks with daylight saving). - Check quiet hours at send time, in the user's zone. Low priority waits (a Mumbai user's 23:30 promo goes out at 08:00); critical ignores quiet hours.
- In a zone with daylight saving, 02:30 may not exist on the day the clocks jump, so re-check when the message is due rather than trusting the stored time.
- A campaign set for 9:00 local time spreads itself out: 10M users over 24 zones is about 417K an hour, roughly 115 a second, instead of 10M at once.
| Where to park it | How it works | Use it when |
|---|---|---|
| Redis sorted set (default) | Scored by notBefore; a scheduler moves what is due onto the real queue. It claims a member by removing it and enqueues only if the removal returned 1, so two schedulers never send the same message | Millions of waiting messages nobody edits. Cost: Redis memory; a restart without persistence loses them |
| Database jobs table | A send time and a status; claim rows with SKIP LOCKED so pollers do not collide | User-visible schedules that must survive anything ("schedule for Friday 9:00", listable and cancellable), up to a few thousand a second |
| Broker delay | No scheduler to run. Standard SQS delays at most 15 minutes; RabbitMQ needs a plugin; Kafka has no per-message delay | Short retry waits only. Use a sorted set or a table for anything longer |
Protecting users. Every extra ping risks an uninstall.
- Daily caps per type: say 3 marketing pushes a day.
- Digests: 20 likes become one push.
- One-click unsubscribe in every marketing email.
- Suppression lists: never email addresses that bounced or flagged you as spam.
Large campaigns. A 10M-user campaign is a batch job, not 10M API calls, and it runs on its own lanes with its own workers. Play Run in pages, then Pause a typo.
- Page the audience. Read the segment in pages of about 1,000 and enqueue each page as a batch on the low lanes. Never load 10M ids into memory or one queue write. Keep a cursor so a crashed job resumes at the last page, and deterministic ids make a repeated page harmless.
- Throttle to the provider limit. The provider sets the speed, not your queue. At 5,000 a second, 10M messages take 2,000 s, about 33 minutes; at 1,000 a second, nearly 3 hours. Throttle the low-lane workers and leave headroom for critical traffic. A time-zone campaign spread over a day (about 115 a second) needs no throttle.
- Pause and cancel. Workers read the campaign status before each send, so one write stops a typo within a second or two. Pause when the copy is fixable; cancel when it should never finish, and workers drop what is left as they see the status.
Tap any box to see what it is and what it talks to.
- Service
- Database
- Queue
- Worker
- Cache
- External API
Text description
- The job reads 1,000 user ids at a time from the segment, never all 10M into memory.
- It enqueues the batch on the low lane and asks for the next page.
- Low workers take messages at the rate the provider allows, 5,000 a second in this example.
- 10M messages at 5,000 a second take 2,000 s, about 33 minutes. Critical traffic uses its own lane the whole time.
Show data table
| min | |
|---|---|
| 1,000 a second | 167 |
| 5,000 a second | 33 |
| 50,000 a second | 3.3 |
Providers and failure modes
Every provider will be slow or go down, so hide each one behind a send(msg) call, a , and a backup.
Read more · 472 words
Without a breaker, each OTP waits out a long timeout on a dead SMS provider, then fails. After about five failures in a row the breaker opens for 30 s and workers send through the next provider. Raise the error rate in the simulator until the breaker opens, then lower it and watch a trial request close it.
is for every channel with a second provider: SMS and email for sure, push rarely (one push service per platform). The cost is a second contract and sender id, and a periodic check that the backup still works. Track delivery rate per provider too (see the reliability lesson).
Not every error deserves a retry. Ask in this order:
| Question | Yes means |
|---|---|
| Permanent (bad number, unregistered token, hard bounce)? | Never retry. Mark failed, delete the token or suppress the address. Retrying a dead number wastes quota |
| A rate limit (429)? | Slow the lane down. Honour Retry-After, lower the worker rate, requeue with backoff. Failing over only moves the overload |
| A backup with a closed breaker? | Try the backup now, in the same attempt: a critical message loses milliseconds, not a retry cycle |
| None of these | Requeue with backoff and jitter; after about 5 attempts move it to the dead-letter queue with an alert |
Four things go wrong in production that the happy path never shows.
| Failure | What happens | Fix |
|---|---|---|
| Accepted, not delivered | A provider returns success and still drops messages, so error rates stay green. Say provider A accepts every SMS but only 40% of receipts return where 95% is normal | Count delivery receipts per provider over 5 minutes, alert below a threshold, route to the backup. Do this before adding retries: retries cannot fix a quiet drop |
| Retry storm | When a provider recovers, every waiting worker retries at once and it falls over again | Exponential backoff with jitter on every retry; let the breaker send one trial request when half-open |
| Poison message | One message that fails every time blocks the lane | Retry a few times with growing delays, then move it to the dead-letter queue and page someone. A non-empty DLQ with no alert is a silent loss. Replay is safe: deterministic ids and sent markers skip anything delivered |
| Everyone opens the app | A push to 2M users at 9:00:00; 5% tapping within a minute is 100K opens, about 1,700 a second of extra traffic | Spread large sends over minutes or by time zone, and cache what the landing screen loads |
Switch the retry policy in the simulator between fixed delays and full , and watch the peak after the 10 s recovery.
Tap any box to see what it is and what it talks to.
- Worker
- External API
- Service
- Database
Text description
- Provider A returns 200 for every send, so the breaker stays closed and the error dashboard stays green.
- But the delivered receipts that normally come back are missing for most messages. A carrier is filtering the traffic.
- Nobody is looking at receipts, so nothing happens. Users wait for codes that never arrive.
Quick checkProvider returns 200 but few users get SMS. What catches it?
Dedupe and idempotency
Duplicates come from client retries and from redelivery after a crash, so defend in three layers, each catching a different source.
Read more · 248 words
Use all three; none is enough alone.
| Layer | How it works | Use it when / limit |
|---|---|---|
| 1. API idempotency key | The caller sends a key; the API stores it with SET NX, and a retry gets the same notificationId back | Every write endpoint a service may retry. Keep keys about a day. It misses the same event emitted twice with two different keys |
| 2. Deterministic message ids | Name each message eventId:channel, so event 812 always yields 812:push, 812:email and 812:sms, however often it is processed | The dedupe key for everything downstream: sent markers, the deliveries primary key, the provider's idempotency key. Never a random id or timestamp: those change on each retry |
| 3. Check before sending, mark after | Skip ids already marked sent; write the marker only after the provider accepts | Marking after can resend if the worker crashes in between: a rare duplicate. Marking before can lose a message when the send fails: worse |
Pass the id as the provider's own where it has one, and the rare duplicate disappears too. Play mark after, crash before marking and mark before (wrong).
to a phone is impossible. Aim for effectively once (async messaging lesson).
- Queue
- Worker
- Cache
- External API
Text description
- Queue sends (async) to Worker: deliver 812:sms
- Worker → Sent markers: sent 812:sms?
- Sent markers replies to Worker: no
- Worker → Provider: send, key 812:sms
- Provider replies to Worker: accepted
- Worker → Sent markers: SET sent 812:sms
- Worker replies to Queue: ack
Code
deliver.js skips open breakers, fails over, and writes sent:<id> only after acceptance.
Read more · 46 words
Try the notification system challenge: build the pipeline on a canvas and get graded.
// One deterministic message per enabled channel, routed by priority
import { nextAllowedSendTime } from './quiet-hours.js';
const PRIORITY = { otp: 'critical', order_update: 'high', marketing: 'low' };
export async function notify({ eventId, userId, type, data }) {
const prefs = await getPrefs(userId); // cached: { push, email, sms, types, quietHours, tz }
const priority = PRIORITY[type] ?? 'normal';
const channels = ['push', 'email', 'sms'].filter(ch => prefs[ch] && prefs.types?.[type]?.[ch] !== false);
for (const ch of channels) {
await queue.publish(`notif.${ch}.${priority}`, {
id: `${eventId}:${ch}`, // deterministic → safe to dedupe
userId, type, ch, data,
notBefore: priority === 'low' ? nextAllowedSendTime(prefs, new Date()) : Date.now(),
});
}
}Notification system in the interview
Say first that you are separating urgent traffic from bulk. Then five steps.
Read more · 515 words
0 to 8 min: name the types and their urgency
Which channels, and which messages are time-boxed? An OTP lives about 5 minutes; a promo can wait.
8 to 15 min: put numbers on it
500M a day is about 5,800 a second, bursts above 50K. API:
202 Accepted, an .15 to 25 min: draw the pipeline
Accept, check preferences, one message per channel onto queues split by channel and priority, dedicated workers, providers.
25 to 40 min: take two deep dives
(
eventId:channel, mark after acceptance) and provider failure (breakers, , delivery rate). Offer campaigns and quiet hours.40 to 45 min: own the trade-offs
with effectively-once results, idle critical capacity, a delayed queue to run.
| If the interviewer says | You raise |
|---|---|
| "An OTP must arrive in seconds" | Priority lanes with dedicated workers and reserved provider rate |
| "Do not send it twice" | Deterministic ids, sent marker after acceptance, provider idempotency key |
| "The SMS provider is down" | Circuit breaker, failover order, delivery rate per provider |
| "Send to 10M users tomorrow at 9:00 local" | Paged batch job, throttle, delayed queue per time zone, pause |
| "Users complain about too many pings" | Daily caps, digests, quiet hours, one-click unsubscribe |
Quick checkWhich design decision do you state first in a notification interview?
Interview questions
How do you make sure a notification isn't sent twice?
Queues deliver at least once, so on eventId:channel, marked sent only after the provider accepts.
A 10M-user campaign is running. How do OTPs still arrive in seconds?
Priority lanes with dedicated workers: the campaign can only fill the low lane.
The SMS provider goes down. What happens?
The breaker opens and traffic fails over to the backup.
How do quiet hours work across time zones?
Store a time zone per user and check at send time. Low priority waits in a delayed queue; critical never does.
The SMS provider returns success, but users say no code arrives. What do you check?
Delivery receipts per provider. A falling delivery rate over 5 minutes means it accepts but drops, so alert and route traffic to the backup.
How do you send 10M campaign notifications without hurting the system?
As a throttled batch job on separate workers, with a way to pause.
Check yourself
Question 1 of 5Score 2 out of 3 or better to mark this page complete.