Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Intermediate/I13
20 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDI13 · Part 2 · Intermediate

Walkthrough: a notification system

A notification system looks like a loop over users and a call to an API, until you remember that the APIs are other people's, the users can say no, and a retry can wake someone up twice.

22 min read2 diagrams11 sections0 exercises
20 of 36

In this chapter

  • What the question is really about
  • Capacity estimation
  • The architecture
  • The four providers, and what each one forces on you
  • Templates: render late, check size, localize

and 6 more

about 22 min
11On this page
  1. What the question is really about
  2. Capacity estimation
  3. The architecture
  4. The four providers, and what each one forces on you
  5. Templates: render late, check size, localize
  6. Preferences and opt-outs: checked at the last moment
  7. Reliability: retries, duplicates and unknown outcomes
  8. Rate limiting, priority and expiry
  9. Delivery tracking and analytics
  10. Failure modes and follow-ups to volunteer
  11. Recognizing it in an unseen problem

What the question is really about

"Design a notification system" (a chapter of Alex Xu's first volume) is a question about fan-out through other people's services. You will never deliver a push notification yourself: Apple and Google do, a carrier does for SMS, a mail provider does for email. Your system is the part in front of them. It decides whether to send (preferences, rate limits, duplicates), what to send (templates, locale, size limits), when (priority, quiet hours, expiry), and what to do when the provider says no or says nothing. Every one of those is a design decision with a number attached, and the walkthrough below attaches them.

Scope for this chapter: push (iOS and Android), SMS and email, triggered by other services (an order shipped, a password reset) or by a campaign tool (a promotion to millions). Out of scope: building a mail server or a carrier connection. Requirements worth saying aloud:

  • Soft real-time. A one-time passcode should arrive in seconds; a marketing push can wait minutes.
  • Never send what the user refused. This is the one requirement with legal weight, so it is checked as late as possible.
  • At-least-once with de-duplication. Losing a shipping notice is bad, sending it three times is also bad; both are prevented by design, not by hope.
  • Isolation. A slow SMS provider must not delay push, and a 20-million-user campaign must not delay a passcode.

Capacity estimation

The inputs are assumptions chosen to be round; copy the method. Provider figures are cited in the next section.

  • Transactional baseline: 10 million notifications a day ÷ 86,400 s = 116 a second; at a 5× peak, 579 a second.
  • Campaign burst: one marketing push to 20 million devices, sent over one hour: 20,000,000 ÷ 3,600 = 5,556 a second. Throughput here is set by the campaign, not by the baseline.
  • Provider ceiling check (push): FCM's default quota is 600,000 messages a minute per project, which is 10,000 a second. The campaign plus the baseline peak is 5,556 + 579 = 6,135 a second, or 61% of quota. Sending the same 20 million, one message per device token, as fast as the default quota allows would take 20,000,000 ÷ 10,000 = 2,000 s, about 33 minutes. Google lets you request an increase of up to 25% when you meet its criteria, which would still leave a floor of about 27 minutes. The estimate also treats all 20 million devices as FCM sends, an upper bound: iOS devices can be sent to APNs directly.
  • Concurrency to hold that rate: by Little's law, in-flight requests = rate × latency. At an assumed 50 ms per provider call, 5,556 × 0.05 = 278 requests in flight, which a modest worker pool with multiplexed connections handles.
  • Email campaign: 5 million emails over two hours: 5,000,000 ÷ 7,200 = 694 a second.
  • SMS is the bottleneck. For 1 million message segments: at 1 segment a second (Twilio's example for a US long code) that is 1,000,000 s, 11.6 days; at a short code's 100 a second, 10,000 s, 2.8 hours. Twilio also queues for at most 10 hours, which at 1 a second is 36,000 segments, so one long code cannot even hold a 1-million-message backlog. The sender type is a requirements decision, not a tuning knob.
  • Device tokens: 20 million users × 2.5 devices = 50 million tokens × 200 B = 10 GB. It fits in memory on one cache cluster.
  • De-duplication keys: 30 million notifications a day (10 million baseline plus the 20 million campaign) × an assumed 100 bytes a key = 3 GB for a 24-hour window.
  • Delivery log: 30 million notifications × 4 status events (queued, sent, delivered, opened) × 100 B = 12 GB a day; 90 days is 1.08 TB, which assumes a campaign that size runs every day, so it is an upper bound. Small enough for one partitioned store, large enough that you will want a retention policy.

The architecture

producers services, campaign tool notification service validate, dedupe preferences, rate limit pick channels, render priority, expiry push queue 3 lanes SMS queue 3 lanes email queue 3 lanes workers workers workers APNs, FCM push SMS provider email provider delivery log and analytics lanes: high (passcodes), normal, bulk (campaigns) callbacks flow back (dashed)
One queue per channel isolates providers from each other; three lanes per queue isolate a passcode from a campaign. Workers are the only part that knows provider protocols.

Why a queue per channel (the queues chapter covers the mechanics): the channels have different throughput ceilings by orders of magnitude (the SMS arithmetic above), different failure modes and different retry policies, so a backlog in one must not block the others. Why lanes: a shared queue means a 4-hour campaign backlog delays a password reset. Put passcodes and security alerts in a high lane with reserved worker capacity, transactional in normal, and campaigns in bulk. Why the service does the checking, not the workers: preferences, de-duplication and rate limits are decisions about the user, made once per notification; workers only know how to talk to a provider.

The four providers, and what each one forces on you

ChannelFacts that shape the designDesign consequence
APNs (iOS)HTTP/2 and TLS 1.2 or later. Payload at most 4 KB (4,096 bytes), 5 KB for VoIP. apns-priority 10 sends immediately, 5 takes the device's power into account, and priority-5 traffic may be grouped, throttled and sometimes not delivered. apns-expiration of 0 means attempt once and do not store. apns-collapse-id (at most 64 bytes) merges notifications. APNs is best effort, may reorder, stores only one notification per bundle ID for an offline device (usually the latest) and for 30 days or less. Status 410 Unregistered means the token is no longer validKeep payloads small and put the detail behind a fetch on tap. Set expiry per message type: zero for a passcode that is useless in a minute. Use collapse ids for "score updated" style messages. On 410, delete the token and stop. A 200 means APNs accepted the request, not that a phone received it
FCM (Android, web, and iOS via APNs)Payload at most 4,096 bytes for both message types. A message with no TTL lives for up to four weeks; on Android and web the TTL can be 0 to 2,419,200 s (28 days). Collapse keys replace an undelivered message with the same key. Default send quota 600,000 messages a minute per project; per-device limits of 240 messages a minute and 5,000 an hour on Android. A 429 carries a retry-after header, with a 60 second default when it is absent. Traffic more than doubles for the first 30 seconds to 2 minutes of each hour, with smaller spikes at :15, :30 and :45Use short TTLs for time-bound messages and collapse keys for state. Budget against the quota (above), respect retry-after, and stagger campaign sends away from :00, :15, :30 and :45
SMS (Twilio as the example)One message segment per second for a US long code in Twilio's example, short codes at over 100 a second; queue of up to 10 hours. 160 GSM-7 characters or 70 UCS-2 characters in one segment, 153 or 67 per segment once split. One character outside GSM-7 switches the whole message to UCS-2. A STOP reply puts the number on a block list and later sends fail with error 21610Choose the sender type from the volume. Count segments at render time and reject or trim: a 161-character GSM-7 message is two segments, and 100 characters with one emoji is two segments where the plain text was one. Honour STOP in your own preferences too, not only at the provider
EmailGmail's rules for senders of more than 5,000 messages a day to Gmail addresses: SPF, DKIM and DMARC; marketing and subscribed mail needs one-click unsubscribe (a List-Unsubscribe HTTPS URI and List-Unsubscribe-Post: List-Unsubscribe=One-Click, the mechanism of RFC 8058); user-reported spam rate below 0.10% as a target and never 0.30% or more. Under CAN-SPAM, opt-outs must be honoured within 10 business days and the mechanism must work for 30 days after sending; each violating email carries a penalty of up to $53,088Separate transactional and marketing mail onto different sending domains or streams so a marketing complaint spike does not hurt a receipt. Process hard bounces and complaints into a suppression list and never retry them. Treat 10 business days as a legal outer limit and aim for seconds

A caution on all four rows: provider limits change, and several are account-specific (quotas, sender throughput). Quote them as "the documented default as of this writing", and design the sender as a thin adapter so replacing a provider changes one module.

Templates: render late, check size, localize

A producer should send an event, not a finished message: {type: "order_shipped", user_id, order_id, carrier, eta}. The service maps the type to a template per channel and locale, with a version. Three decisions:

  • Render at the worker, store the template id, version and variables. You keep an auditable record of what was sent without storing millions of rendered bodies, and a fix to a typo applies to jobs still in the queue. The cost is that the worker must have the template; cache it by version.
  • Validate at render time, not at the provider. A missing variable should fail the job to the dead-letter queue, not send "Hi {{name}}". Check the push payload against 4,096 bytes and the SMS against your segment budget; an oversized payload is a permanent failure, so it must never enter the retry loop.
  • Locale falls back, never fails. User locale, then language, then default. Email carries an HTML part and a plain-text part.

Preferences and opt-outs: checked at the last moment

The preference store is a map from (user, category, channel) to allowed or not, plus quiet hours and a time zone. It has a few properties that matter more than its schema:

  • Category matters more than channel. A user can silence promotions on every channel and still want a shipping notice. Mark each notification type as transactional, marketing or security. As a design policy (chosen to satisfy the strictest regimes: US law lets marketing email run on opt-out, while SMS marketing and EU rules need prior consent), marketing requires an opt-in and honours every opt-out; transactional ignores marketing opt-outs (but not a channel-level STOP at the SMS provider, which is blocked regardless); security is not user-silenceable.
  • Check twice, and the second check is the real one. The service checks when it creates jobs, which is cheap and saves queue space. The worker checks again just before it calls the provider, because a campaign at 5,556 a second takes an hour to drain and someone will unsubscribe in minute ten. That second check is the last point at which an opt-out can still be honoured.
  • Read path is hot, write path is rare. Cache preferences by user; invalidate on write; fail closed (do not send marketing) if the store is unreachable, fail open for security messages.
  • Provider-level state wins. Mirror STOP replies, email unsubscribes and complaints into the same store, so one source of truth feeds all channels.
  • Quiet hours mean a scheduled job, not a sleeping worker: if it is 2 a.m. for the user, enqueue with a delayed visibility time for 8 a.m., and apply an expiry so a stale message is dropped rather than delivered a day late.

Reliability: retries, duplicates and unknown outcomes

The queue gives at-least-once delivery, so every layer of this system must tolerate a repeat. There are two sources of duplicates: the producer calling twice (it timed out waiting) and the worker retrying after an ambiguous failure.

  • De-duplicate at the front door. Every event carries an event id (or you derive a key from user, type and business id, such as the order id). The service does SET dedupe:{id} NX EX 86400 in a key-value store: if the key exists, drop the event. The 24-hour window costs 3 GB at the estimate above. It cannot cover a producer that replays a week later, which is a correctness limit to state.
  • Classify errors before retrying. Retryable: timeouts, 5xx, and 429 (with the provider's delay). Permanent: bad requests, an oversized payload (APNs 413, FCM's 4,096-byte limit), 410 Unregistered or a hard bounce (remove the token or address), a STOP block. Permanent errors skip the backoff ladder and update state (delete the token, suppress the address).
  • Back off with jitter, and cap the budget. The backoff and jitter maths are in the rate-limiting chapter. The retry rules are per provider. For FCM, Google's guidance is to wait at least 10 seconds before a retry, use exponential backoff with jitter, respect retry-after on a 429, and set a maximum age after which a request is dropped instead of retried forever (it gives no number, and says a request still failing an hour later points to a misclassified error or an outage). For APNs, Apple says 5xx responses can be retried after 15 minutes, with back-off if you like, that a 429 TooManyRequests can be retried with a delay, and that BadDeviceToken, DeviceTokenNotForTopic, Forbidden, ExpiredToken, Unregistered and PayloadTooLarge must not be retried. A passcode should expire much sooner than any of this: set the expiry on the job and have the worker drop it when it is stale, instead of delivering a code that no longer works.
  • An unknown outcome is possible. A provider call that times out may have succeeded. Providers differ in whether they de-duplicate; push and email duplicates are annoying, an SMS duplicate costs money, and a duplicate OTP is harmless because the newest code works. Say the trade-off: you accept a rare duplicate rather than a lost message, and you reduce it by keeping the retry count low for non-idempotent channels and using the provider's idempotency or message-id feature where it exists.
  • Dead-letter queue, alerting and replay, exactly as in the queues chapter: bounded retries, a DLQ with the error, an alert on depth and a replay path.

Rate limiting, priority and expiry

  • Per user. Cap marketing at, say, three pushes a day and one email, so a bug or an overeager campaign cannot flood a person. A token bucket per (user, channel, category) held in the same key-value store does it (the algorithm and Redis implementation are in the rate-limiting chapter). The cap applies to marketing and bulk; never to security.
  • Per provider. A global limiter in front of each provider adapter keeps you under its quota. The FCM quota above is 10,000 a second; give the bulk lane a share and reserve the rest so a campaign cannot starve transactional sends.
  • Per campaign. Ramp up. Google's own guidance for FCM traffic is to ramp from zero to the target rate over at least 60 seconds, not to spike on the hour, and to step large increases in stages. Treat the first thousand sends of a campaign as a canary: if the failure rate jumps, pause.
  • Priority is a scheduling policy. Workers pull high first, with a floor of capacity for normal and bulk so nothing starves, or run separate pools. Pass priority through to the provider too: APNs priority 10 for something the user is waiting for, 5 otherwise.
  • Expiry is part of the message. Set a time to live on every job and pass it to the provider (the APNs expiration header, the FCM TTL). A notification that is late enough to be wrong should not be sent.

Delivery tracking and analytics

accepted id is new queued after checks sent provider accepts delivered receipt or callback opened duplicate dropped suppressed or expired failed after retries (DLQ) bounced or invalid address States only move forward. Callbacks arrive late and out of order, so a later state wins and an earlier one never overwrites it.
A notification is a small state machine, and every arrow is a row in the delivery log. A retried failure stays in "queued" until the budget runs out; a bounce comes back from "sent" instead of "delivered". "Sent" means the provider accepted the request, which is weaker than "delivered".

The honest distinction is between accepted and delivered. A 200 from APNs or FCM, or a 202 from an email API, means the provider took responsibility, not that a person saw anything. Delivery receipts come back later by webhook for SMS and email (delivered, bounced, complained), and push gives you partial signals at best. Design consequences: callbacks are idempotent and monotonic (take the furthest state); store the provider's message id on the "sent" row so a callback can find its job; and record opens and clicks from your own tracking links, which are approximate and affected by mail clients that prefetch. Analytics is then a separate consumer of the delivery log, not a feature of the sending path: stream the log to a warehouse, and keep the send path free of reporting queries.

Dry run: one shipping notice, with a provider error, a duplicate and a bounce

Event E-9001 (order shipped) for user u42, who allows push and email for transactional messages and has SMS off. Retry delay is the 10 second Google minimum plus jitter (the 13 s is illustrative).

TimeWhat happensDedupe key E-9001Push jobEmail jobDelivery log
0.000 sProducer posts the event. Service does SET NX: succeeds. Preferences allow push and email, SMS off. Two jobs enqueued in the normal laneset, 24 hqueuedqueuedaccepted, queued ×2
0.100 sPush worker re-checks preferences (still allowed), renders 312 bytes (under 4,096), calls FCM: HTTP 503setretryable errorqueuedpush attempt 1 failed
0.150 sEmail worker calls the email API: accepted, message id M-77setwaiting 13 ssentemail sent (M-77)
13.1 sPush retry succeeds with 200setsentsentpush sent
15.0 sThe producer, which timed out at 10 s, posts E-9001 again. SET NX finds the key and the event is droppedhitsentsentduplicate dropped (counted)
61 sEmail provider webhook for M-77: hard bounce, mailbox does not exist. Address added to the suppression listsetsentbouncedemail bounced, address suppressed
2 days laterAnother notice for u42: email skipped (suppressed), push sent. The user is prompted in-app to fix the addressnew keysentsuppressedemail suppressed

Count the outcomes: the user got exactly one push and no email, the duplicate event cost one key lookup, the transient FCM error cost a 13-second delay rather than a lost message, and the bounce permanently improved future sends. What the log cannot tell you is whether the phone showed the push, which is why "delivered" is reported for email and SMS and "sent" for push.

Failure modes and follow-ups to volunteer

  • A provider is down. Its queue grows, retries back off, others are unaffected. Fall back to a second provider where the channel allows (a second email or SMS vendor), and never fall back across channels for marketing: an unsent push is not permission to send an SMS.
  • Thundering campaign. The ramp, per-provider limiter and bulk lane above. Add a kill switch per campaign.
  • Stale tokens. FCM considers a registration stale after about a month without a connection and garbage collects Android registrations after 270 days. Refresh token timestamps from the client, drop stale tokens before sending, and delete on UNREGISTERED. Sending to dead tokens wastes quota and hurts delivery statistics.
  • Time zones. "9 a.m. local" for 20 million users is a rolling send across 24 hours, not a burst: it helps with every limit above.
  • Observability. Alert on queue depth and age per lane, provider error rates, DLQ depth, bounce and complaint rates (against the 0.10% and 0.30% marks) and the fraction of events dropped as duplicates.
Say it like this → "Producers publish events, not messages. A notification service de-duplicates by event id, checks the user's preferences by category and channel, applies per-user and per-provider rate limits, picks channels and writes a job to a per-channel queue with high, normal and bulk lanes. Workers re-check consent right before sending, render the template, call APNs, FCM, the SMS provider or the email provider, classify errors, retry the transient ones with jittered backoff inside an expiry, and delete tokens and suppress addresses on permanent ones. Provider callbacks feed a monotonic delivery log. The numbers that drive it: a 20 million push campaign is 5,556 a second, 56% of FCM's default quota on its own and 61% with the transactional peak, and a million SMS segments at one segment a second would take about 12 days, so the SMS sender type is a requirement, not a detail."

Recognizing it in an unseen problem

  • Signals: "send an email or SMS when...", "push notifications", "alerts", "reminders", "marketing campaign to millions", "OTP", or any design that has to talk to an external messaging provider.
  • The structure to reach for: events in, preference and rate checks, queue per channel, adapters, callbacks to a delivery log.
  • The numbers an interviewer wants: peak rate from the campaign rather than the average, a provider ceiling compared with that rate, and the SMS throughput shock.
  • The pitfalls to name unprompted: checking consent only at enqueue time, one shared queue for passcodes and promotions, retrying permanent errors, treating "accepted" as "delivered", and ignoring expiry.
  • The follow-up you will get: "how do you avoid sending the same notification twice?" Event-id de-duplication with a TTL, idempotent state updates, and an honest statement that the provider call itself can still produce a rare duplicate.
Before you move on
  • Rebuild the capacity estimate: peak rate for the 20-million campaign, its share of FCM's quota, the minimum time at the quota, and the concurrency needed at 50 ms a call.
  • Compute how long a million SMS segments take on a long code and on a short code, and say what that does to the design.
  • Explain why consent is checked in the worker as well as in the service.
  • Say which errors are retried and which are not, for APNs, FCM, SMS and email.
  • Trace the dry run and say what prevented the second push, and what prevented a second email to a dead address.
  • Explain the difference between sent and delivered, and why callbacks need a monotonic state machine.
  • Describe the lanes and say how a campaign is prevented from delaying a passcode.
Sources and further reading
  • Apple: sending notification requests to APNs — HTTP/2, headers, 4 KB payload, priority, expiration, collapse id, best-effort storage
  • Apple: handling notification responses from APNs — status codes, 410 Unregistered, which errors to retry and after how long
  • Firebase: setting the lifespan of a message — TTL range, four-week default, collapsible messages
  • Firebase: message types — notification and data messages, the 4,096-byte payload limit
  • Firebase: FCM throttling and quotas — 600,000 messages a minute default, per-device limits
  • Firebase: scale FCM messaging — retry-after, backoff with jitter, avoiding spikes on the hour, ramp-up
  • Firebase: manage FCM registration tokens — staleness after a month, 270-day expiry, UNREGISTERED handling
  • Google: email sender guidelines — bulk sender authentication, one-click unsubscribe headers (RFC 8058), spam-rate thresholds
  • FTC: CAN-SPAM Act compliance guide — honouring opt-outs within 10 business days, penalties, transactional messages
  • Twilio: advanced opt-out — STOP keywords, the block list and error 21610
  • Twilio: error 30001, queue overflow — one segment a second on a US long code, over 100 on a short code, the 10-hour queue
  • Twilio: SMS character limits and encoding — GSM-7 and UCS-2 segment sizes

Claims in this chapter were checked against these sources in October 2026. Provider limits are documented defaults at that time and several are account-specific. Figures marked "assumed" or "illustrative" (10 million notifications a day, a 20-million-device campaign, 50 ms per provider call, 100 bytes per dedupe key, the 13-second retry) are this chapter's own inputs. The Apple page was read through its documentation-data endpoint.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

✎Design drill

Design a notification system

Your company wants one service that sends notifications to its users by push (iOS and Android), SMS and email. Other internal services trigger most of them (an order shipped, a password reset), and a marketing team launches campaigns to millions of users at a time. Walk me through the design, and show me the numbers that drive it.

Scope: the sending side only, from an incoming event to the provider call and the delivery record. Out of scope: running a mail server or a carrier connection, the campaign authoring tool, and the app-side code that registers devices. Treat these as planning assumptions, not facts about any real service:

  • 12 million transactional notifications a day on average, with a peak of 5 times the average rate. Producers time out and call again, so the same event can arrive more than once.
  • One marketing push to 24 million device tokens, which marketing wants finished within one hour and plans to start at 09:00 sharp.
  • For the push quota check, treat every device and all transactional traffic as push sends through FCM, whose default quota is 600,000 messages a minute for the project.
  • A flash sale needs 200,000 SMS segments sent within 4 hours of launch. Today you have one US long code, which sends 1 segment a second; a short code sends about 100 a second; and the provider fails a message that has been queued for more than 10 hours.
  • Passcodes are also sent by SMS, must still arrive within seconds during the sale, and are useless after 5 minutes.
  • Marketing policy: at most 3 marketing pushes per user per day. Passcodes and security alerts are exempt from that cap.
  • Users can opt out of marketing on any channel at any time. Passcodes and security alerts cannot be silenced.

Work it out on the whiteboard or on paper first. Nothing on this card is saved, and the checklist stays hidden until you ask for it.

Open a starter whiteboard
I13

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. I12Previous · I12Walkthrough: a news feed / chat app
  2. I13you are here
  3. Next · A1The CAP theorem in depthYou never "pick two" — you pick one letter during a partition, and PACELC tells you what you picked for the other 99.9% of the time.19 min read

01On this page

11 sections · 0%

  1. 01What the question is really about
  2. 02Capacity estimation
  3. 03The architecture
  4. 04The four providers, and what each one forces on you
  5. 05Templates: render late, check size, localize
  6. 06Preferences and opt-outs: checked at the last moment
  7. 07Reliability: retries, duplicates and unknown outcomes
  8. 08Rate limiting, priority and expiry
  9. 09Delivery tracking and analytics
  10. 10Failure modes and follow-ups to volunteer
  11. 11Recognizing it in an unseen problem

[] chapters · t top