Courses / System Design Interview / Chapter 35
Module 3 · Design Real Products · Chapter 35 of 39
Design a Notification System
Builds on Distributed Caches, Message Queues, Publish-Subscribe, Rate Limiters and Distributed Task Schedulers. Next up: Design a Ticket Booking System.
Part 1 · Requirements and Scale
The brief
Friday, 8:00 pm. A food delivery app sends a “50% off biryani” push to 20 million users. At 8:02, Priya in Pune logs in on her new phone; her OTP arrives at 8:06, after it has expired. She tries again, gets in and orders dinner for her parents. “Your rider has arrived” reaches her at 8:41, two minutes after Imran rang the bell. Nothing was down. The OTP, the order update and 20 million offers simply waited in one queue, in arrival order. Urgent messages needed a lane of their own.
You are building the notification system for that food delivery app, with about 20 million daily users. Any team should be able to send a push notification, an SMS, an email or an in-app message, from login OTPs and order updates to marketing offers. The urgent ones must arrive within seconds, even while a campaign is going out.
One service for every message
Priya’s evening went wrong because every message shared one path. A food delivery app sends many kinds of messages: a login OTP (one-time password) by SMS, “your order is out for delivery” as a push notification, a receipt by email, a weekend offer to millions, and a red dot on the in-app bell. If every team wired up its own SMS provider and push code, users would get duplicate, badly timed messages, and nobody could say what had been sent.
A notification system is one shared service that every team calls. It decides whether to send (preferences, quiet hours, limits), how (which channels) and what (the final text), then hands each message to its channel and tracks what happened. Each channel has its own path and its own trap:
| Channel | Delivered through | Best for | Watch out for |
|---|---|---|---|
| Push | the phone platform’s push service (Apple’s for iPhones, Google’s for most Android phones) | order updates, chat, reminders | best effort; needs a valid device token and the user’s permission |
| SMS | SMS providers connected to telecom operators | OTPs and critical alerts | costs money per message; in India, business SMS needs registered sender ids and templates |
| an email sending provider | receipts, statements, digests | spam filters; often read hours later | |
| In-app | our own inbox store, shown in the app | a list of past messages | seen only when the app is opened |
Delivered throughthe phone platform’s push service (Apple’s for iPhones, Google’s for most Android phones)
Best fororder updates, chat, reminders
Watch out forbest effort; needs a valid device token and the user’s permission
Delivered throughSMS providers connected to telecom operators
Best forOTPs and critical alerts
Watch out forcosts money per message; in India, business SMS needs registered sender ids and templates
Delivered throughan email sending provider
Best forreceipts, statements, digests
Watch out forspam filters; often read hours later
Delivered throughour own inbox store, shown in the app
Best fora list of past messages
Watch out forseen only when the app is opened
Only in-app is fully ours. Every other channel ends at an outside provider we cannot control, which is why lanes, retries and failover get the deep dives. Push needs one more idea: when the app starts, it gets a device token from the platform, an address for this app on this phone, and sends it to our servers. The requirements, in short:
Send a notification for a user and an event type
Pick channels from rules and user preferences
Templates with variables, in the user's language
Quiet hours, scheduled sends and campaigns
An in-app inbox with an unread count
Track sent, delivered, opened and clicked
OTPs and order updates out within seconds, even during a campaign
Never lose an important message; duplicates rare
Absorb bursts above 10,000 messages a second
Keep working when one provider fails
Building push or SMS delivery ourselves
The campaign design tool
Choosing who should get an offer
How many, how fast
Now size it. Assume the app has 20 million daily users, 5 million orders a day, and two marketing campaigns a day, each sent as a push to every one of those users.
Try it first: each order sends about 6 updates (placed, accepted, cooking, picked up, near, delivered), and OTPs, receipts and digests add about 10 M a day. What is the average send rate, and the rate while one campaign goes out over 30 minutes?
- 30 M order updates + 40 M campaign pushes + 10 M others = 80 M a day, about 930 a second on average.
- One campaign is 20 M pushes in 1,800 s, about 11,100 a second, plus about 1,000 a second of normal traffic: plan for about 12,000.
- The peak is more than ten times the average, and it is planned: two campaigns a day.
The average hides the shape. A campaign is a burst we create on purpose, and the design must absorb it without delaying a single OTP behind 20 million offers. Storage and lookups, by contrast, are modest:
| Quantity | Working | Result |
|---|---|---|
| Notification records | 80 M a day × about 500 B | ≈ 40 GB a day; ≈ 3.6 TB for 90 days, about 11 TB with 3 copies |
| Device tokens | 20 M users × 2 devices × about 200 B | ≈ 8 GB |
| Preferences | 20 M users × about 200 B | ≈ 4 GB: fits in a cache |
| SMS | mostly OTPs, about 3 M a day | each one costs money, so SMS only where push or email will not do |
Nothing in that table strains a database. The hard number is the 12,000 sends a second of a campaign, and the rule that OTPs and order updates must not feel it.
Part 2 · API, Data and the Big Picture
The API
With the numbers known, define how teams call the service. They call one endpoint and get an answer at once; delivery happens later, and 202 Accepted makes that explicit. Here is Priya’s order update, and the other calls:
POST /v1/notifications Idempotency-Key: order-81723-out-for-delivery { "user_id": "u_42", "type": "order_update", "template": "out_for_delivery", "data": { "rider": "Imran", "eta_min": 7 }, "deliver_by": "2026-10-16T20:45:00+05:30" } 202 Accepted { "notification_id": "n_9f3a" } POST /v1/campaigns { "segment": "inactive_30_days", "template": "weekend_offer", "send_at": "2026-10-18T12:00:00+05:30" } PUT /v1/users/u_42/preferences { "offers": { "push": true, "sms": false, "email": true }, "quiet_hours": ["22:00", "08:00"], "tz": "Asia/Kolkata" } POST /v1/devices { "user_id": "u_42", "platform": "android", "token": "..." } GET /v1/users/u_42/inbox?cursor=...
The caller says what happened, for whom and which template; the service decides channels, timing and the final text for each channel and language.
Three fields carry the design:
typemaps to rules: channels, lane, whether quiet hours apply. Callers do not pick channels themselves.Idempotency-Keylets a caller retry after a timeout without Priya getting two messages.deliver_bymarks when the message stops being useful. An arrival estimate (ETA) from 40 minutes ago is noise, so it is dropped rather than sent late.
Services can also publish events such as “order picked up” to a topic and let the notification service subscribe, as in the pub-sub chapter. Then a notification outage delays messages instead of failing orders.
The data model
Behind the API, five tables hold the state. Four are read on almost every send and change rarely; the fifth, notifications, grows by tens of millions of rows a day.
user_id (pk)phoneemaillanguagetimezonequiet_hoursuser_id (pk)category (pk)push, sms, email flagstoken (pk)user_idplatformapp_versionlast_seentemplate_id (pk)version (pk)channel (pk)language (pk)bodysms_registration_idnotification_id (pk)channel (pk)user_idtypetemplate_idtemplate_versiondedupe_keystatuscreated_atsent_atdelivered_atopened_at- preferences user_id → users
- devices user_id → users
- notifications user_id → users
- notifications template_id → templates
notifications is written several times per message (queued, sent, delivered, opened), with one row per channel, keyed by notification id and channel. Partition it by user_id so one user’s history is a single range read, which is exactly what the in-app inbox needs, and expire rows after 90 days.
preferences and devices are read on every send, so keep them in a cache in front of the database. When a user turns offers off, delete their cached entry at once (the distributed cache chapter’s rule): one more offer after an opt-out is the kind of mistake users remember.
templates are versioned: an edit creates a new version, and each notification records the version it used, so you can always see the exact text that was sent. devices needs care: the app sends its token on every start, because the platform can replace it at any time; on logout the token is unlinked, so the next person on that phone does not get Priya’s orders; tokens the push service reports as unregistered are deleted at once.
Keep secrets out of storage and logs. Store the template id and its variables, with sensitive ones such as an OTP masked, rather than the rendered text, and never write a message body into logs. Support staff can still see which message went out and when, without seeing the code inside it.
The high-level design
Put the pieces together. The service accepts a request, runs it through a short decision pipeline, and drops one message per channel onto a queue for that channel. Channels that carry both kinds of traffic get two lanes: urgent (OTPs, order updates, receipts) and bulk (offers, digests). In-app messages need no provider, so the service writes them straight to the inbox store. Channel workers pull from the queues and call outside providers at a rate those providers allow.
Workers are pooled per channel because each channel has its own provider, limits and failure modes. Push workers can scale out for a campaign without touching the SMS workers that carry OTPs, and a slow email provider cannot hold up anything else.
For an OTP, our own part of the journey is small: checks and rendering take a few milliseconds, the urgent lane is usually empty, and the provider call takes a few hundred milliseconds. Most of the wait is the telecom network delivering the SMS, often a few seconds. A backed-up queue would be the one delay we caused ourselves.
Two questions remain before the deep dives: what the pipeline decides for each message, and how lanes keep a campaign away from Priya.
Deciding what to send
Every request passes through the same short pipeline before anything is queued. Each step can stop the message, delay it, or change its channel:
- 1Check the calleris this service allowed to send this type?
- 2De-duplicatekey seen in the last day? return the first result and stop
- 3Load the userpreferences, devices, language, time zone, all cached
- 4Pick channelsthe type's default channels minus the user's opt-outs
- 5Check timing and limitsquiet hours, per-user caps, deliver_by
- 6Renderfill the template in the user's language
- 7Enqueue and recordone message per channel, status queued
The rules per type live in one table, owned by the notification team:
| Type | Channels | Lane | Quiet hours | Per-user cap |
|---|---|---|---|---|
| Login OTP | SMS | urgent | ignored | 3 per 10 minutes per phone number, against abuse |
| Order update | push, in-app | urgent | ignored | none |
| Payment receipt | email, in-app | urgent | ignored | none |
| Offer | push, email, in-app | bulk | respected | 2 pushes a day |
| Weekly digest | bulk | respected | 1 a week |
ChannelsSMS
Laneurgent
Quiet hoursignored
Per-user cap3 per 10 minutes per phone number, against abuse
Channelspush, in-app
Laneurgent
Quiet hoursignored
Per-user capnone
Channelsemail, in-app
Laneurgent
Quiet hoursignored
Per-user capnone
Channelspush, email, in-app
Lanebulk
Quiet hoursrespected
Per-user cap2 pushes a day
Channelsemail
Lanebulk
Quiet hoursrespected
Per-user cap1 a week
Urgent types skip quiet hours and caps; bulk types obey both. Fallbacks live in the same table: if an order update cannot go by push, because Priya has no device token or has push switched off, steps such as “your rider has arrived” fall back to SMS. Offers never fall back to SMS. A new kind of notification is a new row, reviewed once, which is what stops a well-meaning team from sending offers at 2 am.
Part 3 · Deep Dives
Urgent and bulk lanes
Lanes are the heart of this design: without them, one campaign delays every OTP and order update behind it.
Back to Friday, 8 pm. A queue hands out messages roughly in arrival order, so in one shared queue Priya’s update would wait behind 2 million offers for about 3 minutes. Lanes work like the separate counter at a railway booking office: the long general line never reaches it. Priority must hold at every shared stage, or the line forms at the next one:
- Intake: the campaign job feeds the pipeline in paced batches, so offers never pile up in front of an OTP request.
- Queues: each channel that carries both kinds has an urgent lane and a bulk lane.
- Workers: each lane has its own workers, so offers cannot occupy all of them.
- Provider quota: workers share one rate limit per provider account, and the urgent lane keeps a reserved share.
Predict before you watch: 2 million offers wait in the bulk lane when Imran picks up Priya’s order. How long does her push wait?
- Well under a second: the urgent lane is empty, with its own workers and quota.
8 pm: the campaign job feeds offers in paced batches, and 2 M wait in the bulk lane
Read it as steps
Watch for Priya’s order update: 2 million offers wait in the bulk lane, but the update has its own lane, workers and share of the quota.
Lanes make nothing faster; they stop a patient burst from delaying impatient messages. A campaign may delay other offers, never an OTP or an order update.
Size the reserved share from numbers. Say the push provider allows this account 15,000 sends a second (real quotas are agreed with each provider). Cap bulk at 11,000, enough for a 20 M campaign in 30 minutes, and keep 4,000 for urgent, about four times the dinner-peak rate of order updates. The cost is idle quota on quiet evenings.
Retries and failover
Lanes keep Priya’s messages from waiting on us. Providers fail in their own ordinary ways: timeouts, 5xx errors, throttling with 429, and permanent rejections such as an invalid number or a device token from an uninstalled app. The worker treats each differently:
| Provider response | Worker action |
|---|---|
| Accepted | mark sent, then acknowledge the queue message |
| Timeout or 5xx | retry with exponential backoff and jitter (about 1 s, 2 s, 4 s), up to an attempt limit or deliver_by |
| 429 throttled | slow down for this provider; retry after the time it asks for |
| Token not registered | delete the token; do not retry |
| Invalid number or email | mark failed; flag the contact so the user can fix it |
| Retries used up | move to a dead-letter queue and alert |
Only temporary failures are retried; retrying a permanent rejection wastes money and quota. Each SMS and email provider also sits behind a circuit breaker: if its error rate stays above a threshold, say 20% over a minute, workers stop calling it and use the standby provider.
Priya’s OTP goes to provider A, the primary, which gives no answer in 2 s
Read it as steps
Watch for the circuit breaker: once provider A keeps failing, no OTP waits on it, and test sends bring it back later.
Two rules made Priya’s OTP fast. An OTP’s retry goes straight to the other provider, because backoff against a failing provider spends a waiting user’s patience. And the breaker spares everyone else the timeouts. Push has no standby, since each platform has one push service: pushes wait until their deliver_by, and critical steps fall back to SMS.
Duplicates
Retries and failover keep messages moving, but every retry risks a second copy. The queue delivers at least once (see the message queue chapter): a worker can call the provider, then crash before acknowledging, and another worker receives the same message. So de-duplication happens in layers, each an atomic check, such as a set-if-absent with a one-day expiry, so two copies arriving together cannot both pass.
Watch for the status check: worker 1 asks it first, and worker 2 asks it again
Read it as steps
Watch for the status check: worker 1 saved ‘sent’ before it crashed, so worker 2 skips the provider.
The status check turns a redelivery into a no-op. One window stays open: a crash after the provider accepted the message but before the status was saved. The status check is one of four layers:
| Layer | Key | Stops |
|---|---|---|
| API | Idempotency-Key from the caller | a caller retrying after a timeout |
| Event | user + event + order id, such as u_42:out_for_delivery:81723 | the same event published twice, or redelivered by the topic |
| Worker | the message’s status (notification id and channel), checked before calling the provider | redelivery after a worker crash |
| Device | a collapse id, so a newer push replaces the older one | a stack of stale ETAs on the lock screen |
Each layer catches a repeat the others cannot see.
If a provider accepts a message and its reply is lost, as with Priya’s OTP at provider A, the worker cannot know it was sent. Retrying risks a duplicate; not retrying risks a loss. For OTPs and order updates, prefer the rare duplicate: the login service creates the code once, so a repeat carries the same code. Where a provider accepts an idempotency key, pass the notification id.
Part 4 · Failures and Trade-offs
What can fail
Most failures here start somewhere else: a provider slows down, a phone platform rejects tokens, a caller floods the API. The design’s job is to keep each failure inside its own lane:
| Failure | Effect | Handling |
|---|---|---|
| An SMS provider goes down | OTPs stall | the circuit breaker opens; OTPs go through the standby provider |
| The push service is slow or down | pushes arrive late | retry until deliver_by; critical steps fall back to SMS |
| A campaign floods the system | the bulk lane backs up | the urgent lane, its workers and its quota are untouched |
| The notification service is down | direct calls fail | event publishers lose nothing; direct callers retry with the same idempotency key |
| The preferences store is down | opt-outs cannot be checked | serve from cache; if unknown, send urgent messages and hold bulk ones |
| A bad template ships | broken text to millions | new versions go to 1% first; a kill switch per template and campaign |
| A zone or region fails | a zone takes some workers down; a region stops all sending | queues and stores span three zones; a standby region restarts OTPs and order updates first while campaigns pause, accepting a few duplicates from lagging de-dup records |
| A deploy restarts the fleet | in-flight messages are redelivered; apps with a live in-app connection reconnect | drain workers a few percent at a time, never during a big send; the status check absorbs redeliveries; apps reconnect with jitter |
In every row the urgent path survives: failures delay bulk work or cost a rare duplicate, but never strand an OTP behind someone else’s problem.
Every campaign and every template needs an off button that works within seconds. Sending 20 million wrong messages cannot be undone; stopping after 50,000 is a much better day.
Trade-offs and running it
Each choice above trades something away. Here are the main ones, with the usual pick:
| Decision | Options | Usually pick |
|---|---|---|
| Delivery guarantee | at most once, or at least once | at least once, with layered de-duplication |
| Queues | one shared queue, or per channel and priority | per channel, with urgent and bulk lanes |
| Quiet hours | drop, or delay | delay, unless deliver_by has passed |
| How callers reach us | direct API calls, or events on a topic | events for business changes; the API for direct requests such as OTPs |
| Providers | one per channel, or two | two for SMS and email; push has one service per platform |
Every pick favours the urgent message over the cheap or simple option. Three signals show whether that is working:
What dominates the bill: SMS, about 3 M a day, each one paid for, while push is free to send and email costs a small fraction per message. That is why OTP templates stay short and offers never fall back to SMS.
Part 5 · Going further
The burst that cannot be spread
Optional: four further topics, starting with a burst that the bulk lane cannot absorb.
Campaigns are big but patient: an offer at 8:25 is as good as one at 8:00, so the bulk lane drains it over half an hour. Some messages lose their value in minutes. During the cricket World Cup final, the app runs an innings-break deal, “free dessert for the next 15 minutes”, and must tell 10 M users within 2 minutes, or the break is over.
That is 10 M ÷ 120 s ≈ 83,000 sends a second, about seven times the campaign rate, and none of it can be smoothed out. At the bulk lane’s 11,000 a second it would take 15 minutes. It needs its own preparations:
| Tool | What it does | Why it helps |
|---|---|---|
| Pre-sharded audience lists | Before the match, keep the audience’s device tokens and languages in shards of about 10,000 | No audience query at send time; each shard is one independent job |
| Many workers, warmed up | Start them before the break, since scaling up takes minutes | At about 1,000 sends a second each (an assumption), 100 workers cover 83,000 with some room |
| One render per language | Fill the template once per language, not once per user | Ten renders instead of ten million |
| Agreed provider quota | Raise the push quota with the provider well in advance, and load-test together | The normal 15,000 a second is the ceiling otherwise |
The quota is the real limit: workers are easy to add, and 83,000 a second is more than five times the account’s normal quota. Keep the safety rules while you go fast. The urgent lane keeps its workers and reserved quota, so Priya’s order updates still flow; each deal push carries a deliver_by 2 minutes out and a collapse id. Where the platform offers topics, one send can reach every subscribed device, at the cost of per-user status and caps.
Preferences, quiet hours and limits
The next topic is how users stay in control. Preferences are stored per category and channel: Priya can keep order updates on push but turn offers off everywhere. Some messages ignore preferences because the user asked for them or rules require them, such as OTPs and security alerts. Marketing email must carry a working unsubscribe link: laws in many countries require one, and large email providers expect one-click unsubscribe from bulk senders.
Quiet hours are kept in the user’s own time zone. A bulk message that falls inside them is not dropped straight away: the task scheduler (see the task scheduler chapter) holds it until quiet hours end, unless its deliver_by will have passed by then. Held messages are released over the first hour of the morning, so 8:00 does not become a second spike. Here is Priya’s evening; her quiet hours run from 10 pm to 8 am:
| Time | Message | Decision | Why |
|---|---|---|---|
| 9:40 pm | Weekend offer | send now | before quiet hours, under her daily cap |
| 10:30 pm | Flash sale, ends 11:59 pm | drop | quiet until 8 am, and useless by then |
| 10:45 pm | Weekly digest | hold until after 8 am | still useful tomorrow |
| 11:10 pm | Login OTP | send now | OTPs ignore quiet hours |
| 11:20 pm | Late snack: out for delivery | send now | she is awake and waiting for food |
MessageWeekend offer
Decisionsend now
Whybefore quiet hours, under her daily cap
MessageFlash sale, ends 11:59 pm
Decisiondrop
Whyquiet until 8 am, and useless by then
MessageWeekly digest
Decisionhold until after 8 am
Whystill useful tomorrow
MessageLogin OTP
Decisionsend now
WhyOTPs ignore quiet hours
MessageLate snack: out for delivery
Decisionsend now
Whyshe is awake and waiting for food
Quiet hours delay only what can wait; anything Priya is actively waiting for goes at once.
Per-user limits reuse the rate limiter chapter’s counters, kept in a shared in-memory store with atomic increments: a daily count per user and category, such as 2 offer pushes a day, and a short window per phone number for OTPs. An offer over its cap is dropped, not saved for later. Limits protect users who still have notifications switched on: someone who disables push because of spam stops getting order updates too.
Templates, languages and campaigns
Next, the words themselves. A template is a message with named gaps that the service fills at send time from the request’s data. Each template has a variant per channel, because a push line, an SMS and an email need different lengths and tone, and a variant per language, falling back to English if one is missing:
template: out_for_delivery version: 7 push, en: Your order is on its way. {rider} will reach you in about {eta_min} min. push, hi: आपका ऑर्डर रास्ते में है। {rider} लगभग {eta_min} मिनट में पहुँचेंगे। sms, en: Order on its way. {rider} arrives in about {eta_min} min. data: { "rider": "Imran", "eta_min": 7 } push, en → Your order is on its way. Imran will reach you in about 7 min.
One template, with a variant per channel and language. Variables are filled in at send time.
Mind each channel’s limits. One SMS holds 160 characters of the basic GSM alphabet (GSM-7): English letters, digits and common punctuation. One character outside it, such as a Hindi letter, an emoji or the ₹ sign, switches the whole message to UCS-2, a 16-bit encoding that fits only 70. Longer texts are split into parts of 153 or 67 characters, and every part is billed, so keep OTP templates short in every language. Push payloads are capped at about 4 KB, and the lock screen shows a line or two.
Campaigns are many small sends. A campaign job reads the audience in batches of, say, 1,000 users and passes each through the normal pipeline, so their own preferences, caps and quiet hours apply. Workers check the campaign’s kill switch before every send, so stopping a campaign also stops messages still waiting in the lane. Three rules keep templates safe:
- Missing variable? Fail that send and alert. Never deliver
Hello {name}to anyone. - Escape per channel. Data placed into an email must be HTML-escaped, so an odd restaurant name cannot break the layout or slip in a link.
- Test before launch. Preview every language, and give new versions to a small share of users first.
Tracking delivery and opens
Last, how do you know what happened to each message? Every message moves through statuses, and each status comes from a different place with different reliability. Providers report delivery and bounces later, by calling a webhook of ours:
| Status | Who tells us | How reliable |
|---|---|---|
| queued, sent, failed | the notification service; the provider’s answer | exact |
| delivered | SMS reports from operators; for email, the receiving server accepting it; for push, the app reporting arrival | good for SMS; an accepted email can still land in spam; partial for push |
| opened | the app reports a tap with the notification id; email opens come from a tiny tracking image | push taps are reliable; email opens are not, since mail apps block or preload images |
| clicked | links pass through our redirect, which records the click | good |
Treat email opens as a rough signal and SMS delivery reports as the dependable one. Status events go onto their own queue and into an analytics store, away from the hot send path, so dashboards can answer whether OTP delivery is dropping for one operator or yesterday’s campaign raised opt-outs.
Tracking also feeds back into sending. Email addresses that hard-bounce go on a suppression list and dead device tokens are deleted, so we stop paying for messages nobody can receive. The in-app inbox is the simplest channel: write a row for the user, bump an unread counter, and let the app fetch the list with a cursor; opening an item marks it read.
Part 6 · Interview kit
The 45-minute answer
A 45-minute round leaves about 40 minutes of design after introductions. Here is how this design fits that clock:
| Minutes | What you say and draw | Key number or decision |
|---|---|---|
| 0–5 | Requirements: four channels, preferences and quiet hours, templates, campaigns, tracking. Non-functional: OTPs within seconds even during a campaign; important messages never lost | the OTP-during-a-campaign requirement shapes everything |
| 5–8 | Estimates that decide something: 80 M sends a day, about 930 a second on average, about 12,000 a second during a campaign | peak is more than 10× the average, on purpose |
| 8–13 | API: POST /notifications with type, template, data, an idempotency key and deliver_by, answered with 202. Tables: preferences, devices, templates, notifications partitioned by user | callers say what happened; the service picks channels |
| 13–22 | Draw: the pipeline, one queue per channel with urgent and bulk lanes, workers, providers, the in-app inbox | per-channel queues and workers |
| 22–37 | Deep dive: lanes held at every shared stage with a reserved quota; then failover behind circuit breakers and layered de-duplication | keep 4,000 of 15,000 pushes/s for urgent; fail over at about 20% errors |
| 37–40 | Wrap-up: provider and region failures, kill switches, what you would monitor, SMS as the main cost | a kill switch that works within seconds |
Lanes carry most of the marks, because they answer the requirement that shapes everything. If they ask what happens when the SMS provider goes down, or whether a user can get two OTPs, go to failover and duplicates; for an innings-break deal that must reach 10 M users in 2 minutes, go to the unspreadable burst.
- Say
- “One queue per channel, with urgent and bulk lanes, their own workers and a reserved share of the provider quota, means 2 M queued offers never delay an OTP.”
- Say
- “Delivery is at least once: workers check the status before sending, so redeliveries are skipped. A rare duplicate OTP beats a lost one.”
- Say
- “SMS costs money per message, so it carries OTPs and critical fallbacks only; offers go by push, email and in-app.”
- Avoid
- “The order service calls the SMS provider directly.” Every team then rebuilds retries, limits and opt-outs, and gets them wrong.
- Avoid
- “We guarantee exactly-once delivery.” A provider can accept a message and lose the reply; aim for at least once with layered de-duplication.
Interview drill
Interviewers often change one thing and watch how you adapt. Try each before opening the answer.
Cricket final night: between innings, order updates jump 10×, from about 500 to 5,000 a second, for half an hour. What do you change?
- Scale the urgent push workers before the match; autoscaling reacts in minutes, and this spike is on the calendar.
- The reserved 4,000 a second is now too small: pause campaigns for that hour so the whole quota is free, and ask the provider for headroom in advance.
- The in-app inbox and the de-dup store see a few times 5,000 writes a second, which their clusters handle.
Product wants one live order card that updates in place instead of six separate notifications. What changes in the data model?
- One record per order, not per message: an activity keyed by order id with a version number, updated in place in the inbox.
- Every update carries the version, and apps ignore anything older than what they show, since retries deliver out of order.
- Push uses the order id as the collapse id, so the platform keeps only the newest; cap updates to one every 15 to 30 seconds.
Finance asks you to halve the SMS bill without hurting logins. What do you change?
- Where the user is logged in on another device, send the code there by push or in-app first, and fall back to SMS only if it goes unused for a few seconds.
- Keep every SMS template within one part: 160 GSM-7 characters, or 70 once a Hindi letter or emoji appears.
- Split traffic across providers by price and delivery rate per operator, and stop SMS fallback for order steps that are not critical.
- Common miss
- Lanes in the queue but one shared provider rate limit: bulk workers use it all, and order updates get 429s.
- Common miss
- No deliver_by on time-bound messages, so a stale ETA arrives after the food.
Wrap-up
The whole chapter on one page, for revision.
Next, the ticket booking chapter, Design a Ticket Booking System, sells 40,000 seats to 10 lakh fans at noon, and sends each ticket through a service like this one.
Part 7 · Check yourself
Quick check
Q1During the 8 pm campaign, Priya’s order-update pushes arrive minutes late, yet the urgent push lane is nearly empty. What is going on, and what is the fix?
Think it through, then check.
- The messages left the lane but are stuck in retries: the provider is throttling the account with 429s.
- Both lanes share one rate limit with no reserved share, so bulk workers use all of it.
- Reserve a share of each provider’s rate for the urgent lane, and alert on 429s per lane.
Q2Product adds a business chat channel for order updates, with pre-approved templates and its own provider. What changes?
Think it through, then check.
- A new channel gets its own queue with urgent and bulk lanes, its own workers, and its own provider rate limit and breaker.
- Templates gain a variant for the channel, with the provider’s approval id stored per version, as SMS already does.
- The rules table gains a column: try the chat channel first, then push, then SMS for critical steps.
Q3A teammate says workers send, then mark sent, so every message goes exactly once. Spot the flaw. After a lost reply, should a worker retry an OTP, and a weekend offer?
Think it through, then check.
- A crash before the mark, or a lost reply, leaves the worker unsure whether it sent, so the honest promise is at least once.
- Retry the OTP: the code is the same, so a duplicate is harmless, while a lost code blocks the login.
- Do not retry the offer: a duplicate annoys the user, and a lost offer costs almost nothing.
Q4At 8 pm, mid-campaign, the whole region running the notification service fails. What do users notice, and what should come back first?
Think it through, then check.
- A standby region takes over within a minute or two, with preferences, devices and schedules already copied.
- OTPs and order updates come back first, since logins and deliveries depend on them; the campaign stays paused.
- De-dup records copied asynchronously may lag, so a few users get a message twice.
Q5Quiet hours hold Priya’s weekly digest until morning. Which building block keeps it, and why not leave it waiting in the bulk lane?
Think it through, then check.
- The task scheduler: a durable delayed task releases it at the end of quiet hours, in her own time zone.
- A queue hands out work now; parking a message for 10 hours would block the lane.
- It survives restarts, spreads releases over the morning, and can drop a message whose deliver_by passes.
All chapters · Previous: Design Collaborative Document Editing