Design a push notification platform
Open in the interactive app: practice, quiz, mock interview →In one minute
- Decide before you deliver. Most of the value is in the eligibility step: user preferences, quiet hours, frequency caps, deduplication and channel choice. Sending is the easy part once the decision is right.
- Isolate traffic by priority. A one-time passcode and a marketing blast must never share a queue. Separate topics and worker pools stop a 100-million-user campaign from delaying a login code.
- Treat delivery as at-least-once and make it look exactly-once. Producers retry, workers crash and providers time out. Dedup keys at ingest, a per-attempt state record before each send, and collapse IDs at the provider keep users from seeing the same alert twice.
- Channels have very different costs and limits. Push is nearly free, email is cheap but reputation-sensitive, SMS costs real money per message. Per-user, per-channel rate limits and an explicit fallback order are cost controls as much as user-experience rules.
- The providers are the bottleneck you do not own. APNs, FCM, email and SMS gateways have their own throttles and failure modes. Shape outbound traffic per provider and treat their feedback (dead tokens, bounces, STOP replies) as data you must act on.
Requirements
Functional
- Internal services send a notification for a user (or a segment) by template ID, with variables, a category and an optional dedup key.
- Deliver on push (iOS and Android), email, SMS and an in-app inbox.
- Honour per-category, per-channel user preferences and unsubscribe links, plus quiet hours in the user's time zone.
- Apply frequency caps per user and channel, and fall back to the next allowed channel when the first is unavailable or capped.
- Schedule sends for later, and support campaigns to millions of users.
- Track per-notification status: accepted, suppressed, deferred, sent, delivered, opened, failed.
Non-functional
- Critical notifications (passcodes, security alerts) reach the provider within 2 seconds p99.
- No duplicate delivery from retries within a 24-hour dedup window.
- Never lose an accepted request; at-least-once to providers.
- Campaign throughput of at least 50k sends per second without hurting critical latency.
- Availability 99.95% for the ingest API.
Out of scope: the content of marketing campaigns, audience segmentation queries, A/B testing of copy, and building our own SMS or email transport.
Back-of-envelope
Assumed numbers for a large consumer app.
- Users: 500M registered, 200M DAU. On average 10 notifications per DAU per day, so 2B a day.
- QPS: 2B / 86,400 ≈ 23k/s on average. Transactional traffic peaks at about 3× (70k/s). A campaign to 100M users spread over 30 minutes adds 100M / 1,800 ≈ 55k/s. Plan for about 150k/s through the pipeline.
- Channel mix: 85% push, 12% email, 2% in-app, 1% SMS. SMS is 20M a day; at roughly $0.005 to $0.05 per message depending on country, that is $100k to $1M a day. SMS volume is a budget line, which is why caps and fallback order matter.
- Storage: a notification record with per-channel status is about 500 bytes. 2B × 500 B = 1 TB a day. Keep 90 days hot (90 TB), then move to compressed columnar storage at about 10× compression: 1 TB × 365 × 5 / 10 ≈ 180 TB over five years.
- Dedup state: 2B keys a day × about 64 bytes with overhead ≈ 128 GB for a 24-hour window, which fits in a modest Redis cluster.
- Provider quotas: FCM's HTTP v1 API gives each project a default quota of 600k messages per minute, about 10k/s, and returns 429 above it (FCM throttling and quotas). The plan above needs several times that for Android alone, so a quota increase has to be requested and the campaign emitter must be shaped to fit what is granted, not just to what our workers can do.
- Bandwidth: APNs caps a payload at 4 KB (5 KB for VoIP) (Apple), and typical payloads are 300 to 800 bytes. 150k/s × about 1 KB with headers ≈ 150 MB/s, or 1.2 Gbps outbound to providers.
API
POST /v1/notifications
{ "user_id": "U81", "template": "order_shipped", "category": "transactional",
"priority": "high", "dedup_key": "order-5521-shipped",
"vars": { "order_id": "5521", "eta": "Friday" }, "send_at": null }
-> 202 { "notification_id": "N01J9...", "status": "accepted" }
-> 200 { "notification_id": "N01J8...", "status": "duplicate" } (same dedup_key within 24h)
POST /v1/campaigns
{ "template": "spring_sale", "segment_id": "S77", "category": "marketing",
"window": { "start": "2026-04-01T09:00", "end": "2026-04-01T12:00", "local_time": true } }
-> 202 { "campaign_id": "C12", "estimated_recipients": 41200000 }
GET /v1/notifications/{id} -> { status, channels: [ { channel, state, provider_id, updated_at } ] }
PUT /v1/users/{id}/preferences { categories: { marketing: ["email"], social: ["push", "email"] },
quiet_hours: { start: "22:00", end: "07:00" }, time_zone: "Europe/Berlin" }
POST /v1/users/{id}/devices { platform: "ios", token: "...", app_version: "8.4.1" }
POST /v1/providers/{name}/events (webhooks from email and SMS providers: delivered, bounced, complaint, STOP)Ingest returns 202 because sending is asynchronous. The dedup key is scoped per user, and a repeat returns the original notification ID so a producer's retry is harmless.
Data model
notifications in a wide-column store, partition key (user_id, month), clustering key notification_id (time-sortable). Columns: template, category, dedup key, and a map of channel to state. Reads are almost always one user's recent notifications, so partitioning by user keeps them on one node; the month bucket bounds partition size.
preferences in a relational or key-value store, keyed by user_id: opt-ins per category and channel, quiet hours, IANA time zone name, locale. Read on every send, so cached and invalidated on write.
device_tokens keyed by user_id: (token, platform, app_id, app_version, last_seen, invalid).
dedup in Redis: dd:{user_id}:{dedup_key} -> notification_id with a 24-hour TTL, written with SET NX EX so the check and the claim are one atomic step.
rate counters in Redis keyed (user_id, channel, window); small, short-lived, loss-tolerant.
suppression list keyed by address or phone number: hard bounces, spam complaints and SMS STOP replies. Checked before every email or SMS, because sending to these hurts reputation or breaks the law.
attempts keyed by (notification_id, channel): state (pending, sent, failed), provider_message_id, attempt_count. This is what makes a worker crash safe to recover from.
High-level design
flowchart LR
P["Producer services"] --> API["Ingest API"]
API --> D[("Dedup (Redis)")]
API --> K["Kafka: critical / standard / bulk"]
CS["Campaign service"] --> K
K --> EL["Eligibility workers"]
EL --> PR[("Prefs + rate counters")]
EL -->|"quiet hours"| SCH["Delay scheduler"]
SCH --> K
EL --> R["Renderer (templates, locale)"]
R --> PW["Push workers"] --> APNS["APNs / FCM"]
R --> EW["Email workers"] --> ESP["Email provider"]
R --> SW["SMS workers"] --> SMSP["SMS provider"]
ESP -->|"webhooks"| FB["Feedback processor"]
APNS -->|"410 / UNREGISTERED"| FB
FB --> PRWrite path.
- A producer calls
POST /notifications. The API validates the template and runsSET NXon the dedup key. A duplicate returns the earlier ID. - The request is written to the Kafka topic for its priority, partitioned by
user_id. The API returns 202 only after Kafka acknowledges withacks=all. - An eligibility worker loads preferences and checks suppression. If the category is opted out, the notification is recorded as suppressed. If the user is in quiet hours and the category is not critical, it goes to the delay scheduler with a release time.
- The worker walks the user's channel order for this category and picks the first channel that is enabled, reachable (a valid token or verified address) and under its rate limit.
- The renderer fills the localised template. The channel worker writes a pending attempt row, calls the provider, then records
sentwith the provider's message ID. - Provider feedback (delivery receipts, bounces, dead tokens) arrives asynchronously and updates status, the suppression list and the token table.
Read path. The in-app inbox reads notifications by (user_id, month) with a page cursor, and the unread badge comes from a per-user counter. Status lookups for support tools read the same partition.
Deep dives
1. The eligibility decision
The decision is a pure function of the request, the user's settings, their recent send history and the current time. Keeping it pure (clock injected) makes it testable and replayable, so you can explain why something was or was not sent.
Quiet hours are the trap. Storing a UTC offset is wrong for any region with daylight saving time, because the offset changes twice a year; store the IANA zone name and convert with a tz database. Windows that cross midnight (22:00 to 07:00) need a wrap-around comparison. The release time should be the end of the window plus a random spread of a few minutes, or every deferred notification in a time zone fires at exactly 07:00:00 and you build your own thundering herd. Firebase's own scaling guidance makes the same point from the provider side: ramp traffic gradually and spread requests across each quota window instead of front-loading them (FCM: sending at scale).
Frequency caps use a sliding window per user and channel, for example at most 3 marketing pushes per 24 hours and 2 SMS per hour. When the first channel is capped, the fallback order decides: a security alert might go push, then SMS, then email; a social notification might go push, then in-app only, and never SMS. Fallback is per category, chosen by product and finance together, because the cheapest-first and fastest-first orders differ.
2. Priority isolation and campaign fan-out
Option one is a single queue with a priority field. Kafka partitions are FIFO, so a passcode sits behind a million campaign messages already in the partition. Option two is one topic per priority with separate consumer groups and worker pools: critical, standard and bulk. I choose option two, and size the critical pool to stay mostly idle.
Campaigns are a fan-out problem (see the Fanout lesson). The campaign service pages through the segment and emits per-user messages into the bulk topic at a controlled rate, so the 41M-recipient campaign above becomes a steady stream instead of a spike. "Local time" campaigns are emitted per time zone as each zone reaches 09:00. Rate-shaping at the source also means a bad campaign can be paused mid-flight by stopping the emitter, the most valuable control in an incident.
3. Duplicates and partial failure
Duplicates come from three places. Producers retry an ingest call that timed out: the dedup key catches that. A worker crashes after calling the provider but before committing its Kafka offset: on restart it reprocesses the message. The attempt row catches most of these; if it says sent, skip. If it says pending, the worker cannot know whether the provider received the request, because APNs and FCM accept no idempotency key. For push, resend with the same replacement key so the device replaces the earlier notification instead of showing two: apns-collapse-id (at most 64 bytes) on iOS, and android.notification.tag on Android. FCM's collapse_key is a different thing: it only collapses messages that are still queued while the device is offline, at most four keys per device, and FCM ignores it for notification messages (FCM collapsible messages). For SMS, where a duplicate costs money and annoys people, prefer to mark it failed and let a reconciliation job check the provider's message log. The third source is upstream: two services both deciding to notify about the same event. A dedup key derived from the event (order-5521-shipped) rather than a random UUID handles that.
Down the stack
Layer 1The contract
- Ingest. A 202 means the request is durable in Kafka on all in-sync replicas. A repeat with the same
(user_id, dedup_key)within 24 hours returns the same ID and causes no second send. - Decision. For a given request, settings and time, the eligibility result is deterministic: suppressed, deferred until T, rate-limited, or sent on channel C.
- Delivery. At-least-once to the provider for accepted, eligible notifications. Critical notifications reach the provider within 2 s p99. No guarantee that the device displays it; the OS and the user decide that.
Layer 2The mechanism
APNs is an HTTP/2 API. Each notification is one POST /3/device/{token} stream on a long-lived TLS connection, authenticated by a signed JWT provider token. The JWT must be refreshed no more than once every 20 minutes and no less than once every 60 minutes, so workers share one cached token instead of minting one per request (Apple). Headers carry apns-push-type, apns-priority (10 for immediate; 5 and 1 for power-friendly delivery that the device may batch, throttle or drop), apns-expiration and apns-collapse-id. A 200 response returns an apns-id. A 410 means the token is dead and must be deleted; the reason is either Unregistered or the newer ExpiredToken, so match on the status code, not one reason string (Apple). FCM's HTTP v1 API takes a JSON message with android.priority, android.ttl and android.collapse_key (and apns and webpush blocks for other platforms) and returns UNREGISTERED (HTTP 404) for dead tokens (FCM error codes). HTTP v1 is now the only option: Google deprecated the legacy HTTP and XMPP APIs in June 2023 and began shutting them down in July 2024, so designs that still mention server keys are out of date.
Email goes through a provider over SMTP or HTTPS. Deliverability depends on SPF, DKIM signatures (RFC 6376) and a DMARC policy, and large senders must offer one-click unsubscribe through the List-Unsubscribe and List-Unsubscribe-Post headers (RFC 8058). Since February 2024 Gmail enforces this: anyone sending more than 5,000 messages a day to Gmail accounts needs SPF, DKIM and DMARC, one-click unsubscribe on marketing mail, and a reported spam rate below 0.3% (Google sender guidelines). Bounces and complaints come back as provider webhooks and feed the suppression list.
Kafka topics are partitioned by user_id, so all of one user's notifications are decided by the same worker in order. That lets the worker keep a hot cache of that user's preferences and lets rate-limit counters be updated without cross-worker races for the same user.
Layer 3The machine
- HTTP/2 multiplexing. One TLS connection to APNs carries many concurrent streams, up to the limit the server advertises in
SETTINGS_MAX_CONCURRENT_STREAMS, which RFC 9113 recommends be at least 100 (RFC 9113). HPACK compresses repeated headers to a few bytes each. A push worker with a few dozen connections can sustain thousands of sends per second, bounded by round-trip time: at 50 ms RTT and 500 streams per connection, one connection supports about 10k requests per second. - Connection reuse. A TLS 1.3 handshake adds a round trip and key-exchange CPU, so workers keep pools of warm connections.
- Redis checks.
SET NX EXandINCRare single-threaded in-memory operations of a few microseconds; the cost is the network round trip, about 0.2 to 0.5 ms in a datacenter. Pipelining the dedup, rate and preference lookups for a batch of 100 messages turns 300 round trips into one. - Kafka. Producers batch messages (
linger.msof 5 to 10 ms; the default changed from 0 to 5 ms in Kafka 4.0 (KIP-1030)) so each disk write and replication round trip carries hundreds of records. Brokers append to page-cache log segments and serve consumers withsendfile.
Why it's built this way
- Sending is irreversible and users punish spam by disabling notifications entirely, so the system invests in a careful, replayable decision before any send.
- Providers do not support idempotency keys, so exactly-once is impossible at the edge; the system dedups everything it can upstream and uses collapse IDs to hide what remains.
- Critical and bulk traffic differ by four orders of magnitude in volume and urgency, so they get separate queues and workers.
- Providers throttle and fail independently, so each channel has its own worker pool, rate shaping and circuit breaker.
- SMS and email reputation have real costs, so feedback loops (suppression, dead tokens) are part of the core design, not an afterthought.
Failure modes
- 10x load (a viral event or a mis-sized campaign). Bulk workers back off first; the campaign emitter has a global rate limit and a kill switch. Critical workers keep reserved capacity. Stale bulk messages past their TTL are dropped rather than sent hours late.
- Provider outage. APNs or an SMS vendor returns 5xx or times out. A circuit breaker per provider stops hammering it; critical messages fall back to the next channel in the user's order, while bulk messages wait. For SMS, keep a second vendor and route by country.
- Region outage. Ingest runs active-active in two regions with dedup state replicated asynchronously. During failover a retried request may slip past dedup; collapse IDs keep push duplicates invisible. Accept a small risk of duplicate emails rather than losing passcodes.
- Hot user or hot key. A buggy producer loops and sends 10,000 notifications to one user in a minute. The per-user rate limit and per-producer quotas at ingest contain it.
- Token rot. Uninstalled apps leave dead tokens that waste capacity and make delivery-rate dashboards look like an outage. Delete tokens on 410 or
UNREGISTERED, and expire tokens your app has not refreshed in a month or two. FCM treats a registration as stale after a month without contact and expires Android registrations after 270 days of inactivity (FCM token management).
In the wild
- Hawaii Emergency Management Agency, 2018: a drill sent as a real missile alert. On 13 January a warning officer sent a live ballistic missile alert to phones (WEA) and broadcasters (EAS) across Hawaii during a no-notice drill. The alert software listed the test and live templates in the same drop-down, behind the same login, and one credentialed person could send either. There was no prepared correction template, so it took 13 minutes for an authoritative social-media retraction and 38 minutes for a correction over the same alert channels. Afterwards the agency required two people for tests and live alerts and wrote correction templates in advance (FCC report).
- GitHub, 2022: undeliverable webhook jobs block the queue. On 5 October, automated accounts created and deleted repositories so fast that many webhook jobs could no longer build their payloads, because the data was already gone. The delivery workers kept retrying them, new events queued behind the retries, and delivery stalled for 31 minutes, which also turned GitHub Actions red. GitHub disabled the accounts and changed the workers to stop retrying jobs whose data no longer exists (GitHub availability report).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One topic per priority | Passcodes never wait behind campaigns | More topics and pools to operate | Mixed transactional and marketing traffic |
| Single queue with priority field | Simple | Head-of-line blocking in FIFO partitions | Low volume, one kind of traffic |
| Dedup at ingest with Redis TTL | Cheap, stops producer retries | Lost on cache failure; window is finite | Almost always, as the first layer |
| Attempt record before send | Survives worker crashes | One extra write per send | Expensive channels such as SMS |
| Collapse IDs at the provider | Hides residual duplicates on device | Push only; replaces rather than prevents | Push notifications |
| Fixed fallback order per category | Predictable cost and behaviour | Not personalised | Most products |
| Learned channel selection | Better engagement | Hard to explain, needs data | Mature products with experimentation |
Leadership lens
- Build first: ingest with dedup, the priority topics, push and email workers, and the preference check. Quiet hours, frequency caps and campaigns come next. Learned send-time optimisation comes last, if ever.
- Ownership: a platform team owns the pipeline and the delivery contract; product teams own templates and categories; a growth team owns campaigns and frequency policy; finance and legal sign off on SMS fallback rules and unsubscribe handling.
- Cost drivers: SMS spend dominates everything else, followed by email volume and storage of notification history. Put a per-team SMS budget and dashboard in place on day one.
- Rollout: migrate producers one category at a time, shadow the new eligibility decision against the old system and diff the outcomes, then cut over with a per-category kill switch.
- Review questions: which categories may bypass quiet hours and who approved that list; what happens to a passcode when push and SMS are both down; how do we show a user why they got a notification.
Check yourself
A campaign to 80 million users starts and passcode latency jumps to 40 seconds. What design choice would cause that, and what fixes it?
Show answer
A single queue with a priority field, because Kafka partitions are FIFO and a passcode sits behind campaign messages already in the partition. The fix is one topic per priority with separate consumer groups and worker pools, plus a campaign emitter that sends into the bulk topic at a controlled rate.
A push worker crashes after calling APNs but before recording success. What happens on recovery, and why can the system not simply guarantee exactly-once?
Show answer
The attempt row says
pending, so the worker cannot know whether APNs received the request, and APNs and FCM accept no idempotency key. It resends with the sameapns-collapse-id(orandroid.notification.tagon Android), so the device replaces the earlier notification instead of showing two.
Using the lesson's numbers, why is SMS treated as a budget line, and what controls follow from that?
Show answer
SMS is only 1% of 2B notifications a day, but 20M messages at $0.005 to $0.05 each is $100k to $1M a day. That is why per-user, per-channel rate limits, a per-category fallback order chosen with finance, and per-team SMS budgets exist.
Why must quiet hours be stored as an IANA time zone name, and what goes wrong if every deferred notification is released exactly at the window's end?
Show answer
A stored UTC offset is wrong in any region with daylight saving time, because the offset changes twice a year. Releasing everything at exactly 07:00:00 creates a thundering herd, so the release time adds a random spread of a few minutes.
Sources and further study
Primary
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures: how email providers verify that a message really came from your domain.
- RFC 9113: HTTP/2: stream multiplexing and
SETTINGS_MAX_CONCURRENT_STREAMS, the transport APNs requires.
Systems docs
- Establishing a token-based connection to APNs (Apple): JWT provider tokens and the 20 to 60 minute refresh window.
- FCM throttling and quotas (Firebase): the per-project send quota, how to ask for more, and what a 429 means.
- Manage FCM registration tokens (Firebase): stale and expired tokens and how to prune them.
- Email sender guidelines (Google): the authentication, unsubscribe and spam-rate rules for bulk senders.
Books
- System Design Interview, Volume 1, Alex Xu, chapter 10 "Design A Notification System": the interview-depth baseline of queues per channel, retries and templates.
- Site Reliability Engineering, Beyer et al. (Google), chapter 21 "Handling Overload": criticality levels and per-customer limits, the ideas behind separate priority topics and producer quotas.
Other curricula
- Azure Architecture Center: Priority Queue pattern: frames the passcode-versus-campaign problem as separate queues with separate consumer pools.
- AWS Builders' Library: Workload isolation using shuffle sharding: frames noisy-producer isolation as limiting the blast radius of one bad tenant.
Watch
- Design A Scalable Notification System (Irtiza Hafiz): a walkthrough of queues, workers and storage choices for a multi-channel notification system.
- How to Design a Notification System (Interview Simplified): an hour-long interview walkthrough covering preferences, retries and channel delivery.
Free resources
- Sending notification requests to APNs (Apple): the HTTP/2 request format, headers such as
apns-collapse-id, and priorities. - Handling notification responses from APNs (Apple): status codes and reasons, including how dead tokens are reported.
- FCM HTTP v1 message reference (Firebase): message fields for priority, TTL and collapse keys across platforms.
- RFC 8058: Signaling One-Click Functionality for List Email Headers: the one-click unsubscribe mechanism large mailbox providers now expect.
- Avoiding insurmountable queue backlogs (AWS Builders' Library): why separate queues, shuffle sharding and dropping stale work keep async systems healthy.
- Making retries safe with idempotent APIs (AWS Builders' Library): the client-token pattern behind dedup keys at ingest.