Preface#
A Friday evening at the super-app: payments, bill pay, group chats, 50 million users, and every product team sending notifications through whatever queue was closest. (An illustrative scene: the people, times and amounts are made up; each failure is a classic one, and the arithmetic uses this lesson's numbers.)
- 19:55 — Marketing schedules a Diwali cashback push to 10 million users for 20:00 sharp. It goes into the same queue as everything else
- 20:00:02 — Arjun pays Priya ₹2,400. Her web app is open, but the bell only refreshes on reload. The phone push waits behind the campaign and buzzes at 20:08
- 20:00:40 — Rohan signs in on a new phone. His OTP SMS waits behind 9.2M campaign messages and arrives at 20:08, after the code's 5-minute expiry. He taps "Resend" three times; each tap buys another SMS
- 20:03 — Meera turned marketing off at 19:59. She gets the cashback push anyway, because the campaign checked preferences once, when it was built at 19:55
- 21:30 — In the "Goa Trip" group, Kabir still shows a green dot. His phone died at 20:12
- Month end — Finance asks why SMS, 5% of the messages, is nearly 90% of the provider bill
None of these needs a clever algorithm. Each is a piece of the platform that wasn't there: a priority lane, a preference check at send time, a durable inbox with a live connection, presence that expires by itself, and a channel policy that knows what an SMS costs.
This week builds those pieces as one system. Today is the whole-system view: requirements, numbers, architecture, data model and channel trade-offs. Each of the next four days goes deep on one part.
Day 1 opens on an illustrative evening: a 10M campaign in the one queue delays a payment push and an OTP to 20:08, an opt-out is ignored, a dead phone keeps its green dot, and SMS is 5% of messages but ~90% of the bill. The requirements give five classes of notification with their own clocks and lanes; the estimate gives ~5,800/s (design for 20,000), push 60% / email 25% / in-app 10% / SMS 5%, SMS ~89% of ~$3.4M a month at an assumed $0.004, and ~10M live connections. The architecture has seven pieces; Arjun's ₹2,400 goes through the Send API and outbox to the transactional lane, is written to Priya's inbox as seq 1184 before gateway gw-17 is told, and is pushed only if no device acks; five records keep sent and delivered apart.
Part I: The Problem#
Chapter 1: What We're Building#
1.1 The Simple Definition#
A notification platform is one shared service that takes "tell user U about event E" from any team, decides whether, how and when to tell them, and gets it there over the right channel. It has two halves, and this week's title is about the second:
- Store and forward through providers. Push (APNs, FCM), SMS and email: we hand the message to someone else's network, and "delivered" mostly means "a provider accepted it".
- Real time to online users. When the app is open, we hold a connection and push down it ourselves, in under a second. Here we are the provider.
The same tier that pushes "₹2,400 received" to Priya's open browser also carries chat messages and the green "online" dots, so a notification platform and a chat backend share their bottom half.
1.2 Why One Platform#
Every company starts with each team calling Twilio or FCM directly, until users notice the sum of it.
Each team sends its own
- Five teams, five queues, five retry policies
- No one knows how many messages a user got today
- Opt-outs in one team's database, ignored by the others
- A campaign and an OTP share one provider quota
One notification platform
- Teams say what happened; the platform decides how
- Preferences, quiet hours and caps applied in one place
- Priority lanes: an OTP never waits behind a campaign
- One inbox and one real-time tier for every product
LinkedIn describes exactly this: one team's logic "about how often or to whom to send notifications would have been isolated to that team", which "led to a non-regulated, excessive, and low-quality member notification experience" (LinkedIn Engineering, 2018).
1.3 The Interview Opening#
"Design a notification system" is a common interview prompt, and its trap is that everyone knows roughly what the boxes are. Don't draw them yet. Spend the first five minutes on questions, using FERNS:
- F — Functional: what types of notification, which channels, what users control
- E — Edge cases: invalid numbers, disabled push, offline users, duplicates
- R — Resources and constraints: budget, providers, regulation
- N — Non-functional: latency per type, delivery guarantees, audit
- S — Scale: users, messages a day, peaks, concurrent online users
Chapter 2: Requirements#
2.1 The Clarifying Dialogue#
"What kind of product is this, and what do we notify people about?"
"A fintech super-app in India: payments, bill pay, group chats where friends split costs. Transactions, security alerts and OTPs, chat and split requests, reminders, and marketing campaigns. Channels: push, SMS, email, and in-app, a bell in the mobile and web apps. Leave WhatsApp out for now."
"When the app is open, should a notification appear live, or on the next refresh?"
"Live. If Priya's app is open when the money arrives, it's on her screen in under a second. Same for chat, and we show who's online."
"What do users control, and what's the scale?"
"Opt in or out per category and channel, quiet hours, one-tap unsubscribe from marketing; security alerts and OTPs can't be turned off. 50 million users, about 10 million daily active, around 500 million notifications a day, and campaigns can add 10 million in an hour. Around 15% of users are online at the evening peak."
"What are the latency requirements?"
"For transaction notifications, within 1–2 seconds. Security alerts should be near-instant. Marketing can be slightly delayed: within a minute is fine."
"And reliability?"
"99.9% delivery for critical notifications. For marketing we can accept some loss. And no duplicates: a user who gets 'payment received' twice thinks they were paid twice."
"If resources are tight, what wins?"
"Transaction and security notifications should never be delayed by marketing campaigns."
"Cost or regulation?"
"SMS is expensive; use it carefully. Indian SMS rules and the mailbox providers' email rules apply, and we need an audit trail."
2.2 The Requirements, Written Down#
Read the summary back before designing. It catches misunderstandings while they're cheap.
Functional
- Accept requests from any service, with templates and per-user variables; schedule and run campaigns
- Channels: push (APNs, FCM), SMS, email, in-app (a bell with an unread count)
- Real time: online users see notifications and chat live; group chats show presence
- Preferences: per category and channel, quiet hours, unsubscribe; critical can't be disabled
- Inbox with read state, a status per channel attempt, and an audit trail
Non-functional
- Scale: 50M users, 500M a day, 10M an hour in campaigns, ~15% online at peak
- Latency: security near-instant, transactions 1–2 s, live in-app under 1 s, marketing within a minute
- Reliability: 99.9% for critical; at-least-once, no visible duplicates; marketing never delays transactions
- Cost and compliance: SMS used sparingly, SMS registration, email sender rules
2.3 Five Classes of Notification#
The most useful thing to write down: notifications are not all the same. Each class has its own clock, channels and tolerance for delay, and the architecture follows from the differences.
| Class | Examples | Latency target | Channels (first → fallback) | Quiet hours | Lane |
|---|---|---|---|---|---|
| Critical | OTP, new-device login, fraud alert | Near-instant (seconds) | OTP: SMS at once. Alerts: push, then SMS if unread after 2 min, then email | Ignored | critical |
| Transactional | Payment received, refund, bill paid | 1–2 s | In-app if online, else push → email (SMS if the user asked for it) | Sent, silently at night | transactional |
| Social | Chat message, split request, mention | ~1 s live, seconds by push | In-app / chat live, else push | Respected | transactional |
| Reminder | Bill due tomorrow, low balance | At a scheduled time | Push → email | Deferred to morning | transactional |
| Marketing | Cashback offers, new features | Minutes; campaign window | Push or email, in-app banner | Deferred, never sent at night | bulk |
The lane column is a preview: Day 2 designs the lanes, and Day 5 makes them hold all the way to the provider.
One queue at 20:00
Go back to the Preface. Every notification shares one FIFO queue, and the workers drain it at the design rate of 20,000 a second. At 20:00:00, 10 million campaign messages are written at once; Rohan's OTP is enqueued at 20:00:40. When does it leave the queue, and what does the answer tell you about the design?
One strong answer
- The wait: by 20:00:40 the workers have drained 800,000, so 9.2M are ahead of the OTP. 9.2M ÷ 20,000 = 460 s, so it leaves at about 20:08, after a 5-minute OTP has expired.
- More workers don't fix it: draining 10M within a minute takes ~167,000 a second, over 8× the fleet, for a burst that lasts eight minutes.
- The design consequence: classes must not share a queue. Critical traffic gets its own lane and workers, and campaigns are paced into a bulk lane (Day 2, Day 5).
Chapter 3: Estimates#
3.1 Before the Numbers#
Two numbers shape this design most: how the 500 million split across channels, and what each channel costs. Guess first.
Guess the bill
The channel mix is push 60%, email 25%, in-app 10%, SMS 5%. Push is free to send, email costs about $0.10 per 1,000, and SMS a fraction of a US cent each. What share of the monthly provider bill is SMS?
One strong answer
- SMS is about 89%. At an assumed $0.004 per SMS, 750M SMS a month cost ~$3.0M; 3.75B emails cost ~$375,000; push costs nothing per message.
- Why intuition fails: "a fraction of a cent" sounds small, but $0.004 is 40 emails. Volume share and cost share are unrelated across channels.
- So every SMS needs a reason, and cutting SMS is the biggest cost lever by far (Day 5).
3.2 The Worked Estimate#
| Value | |
|---|---|
| Users / daily active (the interviewer) | 50M / 10M |
| Notifications a day (the interviewer) | 500M |
| Average rate: 500M ÷ 86,400 s | ~5,800 a second |
| Peak: 3× average (assumed: evening hours and campaign times) | ~17,400 a second → design for 20,000 |
| Campaign: 10M in an hour, if spread evenly | +2,800 a second (+48%); bursts are far worse unless paced (Day 5) |
| Push 60% | 300M a day, ~3,500 a second |
| Email 25% | 125M a day, ~1,450 a second |
| In-app 10% | 50M a day, ~580 a second |
| SMS 5% | 25M a day, ~290 a second → 750M a month |
| Push price: FCM is listed as "No-cost" (Firebase pricing); assume APNs also costs nothing per message | $0 |
| Email price: Amazon SES pay-as-you-go, $0.10 per 1,000 (AWS pricing) | 3.75B a month → ~$375,000 a month |
| SMS price: assumed negotiated $0.004 per SMS on registered local routes | 750M × $0.004 → ~$3.0M a month |
| For contrast: Twilio's list price to India from an international number, $0.0832 per segment | 750M × $0.0832 → ~$62M a month |
| Total at the assumed rates | ~$3.4M a month; SMS ≈ 89% of it |
| Online at the evening peak: 15% of 50M, ~1.33 devices each (Day 3's sizing) | 7.5M people → ~10M live connections |
Conclusion: SMS is 5% of the messages and nearly 90% of the bill; each SMS costs as much as 40 emails. So SMS is a scarce resource: OTPs, critical alerts and genuine push failures only, on registered local routes, each with a recorded reason. And the real-time tier must hold 10 million connections, so it's sized by connections, not by ~580 in-app notifications a second (Day 3).
The route matters as much as the volume. Per AWS's documentation, international routes to India are "significantly higher" in price than local routes, and local routes require registering your use case and message templates with TRAI through a DLT portal (AWS End User Messaging docs). An unregistered template doesn't get the local price.
3.3 What We Store#
Rough sizes, to decide where each kind of data lives (assumed record sizes):
| Data | Arithmetic | Size | Shape |
|---|---|---|---|
| Notifications | 500M a day × ~400 B | 200 GB a day, ~18 TB at 90 days | Append-heavy |
| Deliveries (per channel attempt) | ~1.2 per notification × ~300 B | ~180 GB a day | Updated by callbacks |
| Inbox items | 50M a day × ~500 B | 25 GB a day, ~2.3 TB at 90 days | Read by user, newest first |
| Preferences | 50M users × ~300 B | ~15 GB | Read on every send |
| Device tokens | 50M × 1.5 devices × ~300 B | ~22 GB | Read on every push |
| Connection registry, presence | 10M connections × ~100 B | ~1 GB, in memory | Soft state, expires |
Two families. Preferences and tokens are small and read on every send: cache them. Notifications, deliveries and inbox items are big and time-ordered: partition by user and time, and drop old partitions instead of deleting rows.
Part II: The Architecture#
Chapter 4: The Whole System#
4.1 The Pieces#
%% title: The real-time notification platform: every piece this week builds
flowchart TD
SVC@{ icon: "sd:server", label: "Product services<br/>payments, security, chat, marketing" }
API["Ingest: Send API<br/>validate, idempotency key, 202"]:::core
OB@{ icon: "tech:postgresql", label: "PostgreSQL<br/>notifications + outbox, one transaction" }
K@{ icon: "tech:kafka", label: "Kafka priority lanes<br/>critical · transactional · bulk" }
RT["Pipeline: router<br/>preferences, quiet hours, dedup, schedule"]:::core
PREF@{ icon: "tech:redis", label: "Redis: preferences + tokens<br/>PostgreSQL behind" }
PW["Push workers"]:::service
SW["SMS workers"]:::service
EW["Email workers"]:::service
IA["In-app delivery<br/>inbox write, seq per user"]:::core
APNS@{ icon: "tech:apple", label: "APNs" }
FCM@{ icon: "tech:firebase", label: "FCM" }
SMS@{ icon: "sd:sms", label: "SMS providers<br/>registered local routes" }
EM@{ icon: "tech:sendgrid", label: "Email provider<br/>SendGrid or SES" }
IB@{ icon: "sd:database", label: "Inbox<br/>per user, ordered by seq" }
GW@{ icon: "sd:gateway", label: "Real-time gateways<br/>WebSocket and SSE, ~10M sockets" }
PR["Presence and chat fan-out"]:::service
DEV@{ icon: "sd:phone", label: "Apps and browsers" }
SVC --> API
API --> OB
OB -->|"outbox relay"| K
K --> RT
RT -.->|"lookup"| PREF
RT --> PW
RT --> SW
RT --> EW
RT --> IA
PW --> APNS
PW --> FCM
SW --> SMS
EW --> EM
IA --> IB
IA -->|"live hint"| GW
PR --> GW
GW --> DEV
class SVC service
class OB,IB store
class K queue
class PREF cache
class APNS,FCM,SMS,EM external
class GW edge
class DEV clientSeven pieces, each with one job:
- Ingest. The Send API validates
{user, type, template, variables}and writes the notification and an outbox row in one transaction (Week 3, Day 2 · Transactional Outbox), then returns202 Accepted. The caller's idempotency key turns a retry into the same notification, not a second one (Week 2, Day 2 · Idempotency in Practice). Ingest never calls a provider, so the payment service never waits on one. - Preferences. Consent, per-category and per-channel switches, quiet hours and device tokens. Read on every send, so cached (Week 4, Day 1 · Caching Patterns); written rarely, but an unsubscribe must apply in seconds.
- The pipeline. An outbox relay publishes to priority lanes, Kafka topics with their own consumers. The router looks up preferences and tokens, picks channels, applies quiet hours and caps, drops duplicates, holds scheduled sends, and writes one task per channel.
- Channel workers. One pool per channel, each speaking one provider's protocol within its rate limits. A failure that might heal (a timeout) is retried with backoff; one that won't (an uninstalled app) triggers the fallback channel.
- The inbox. Every in-app notification is written to a durable per-user inbox with a sequence number before anyone is told. The bell, the unread count and the history all read from it.
- The real-time gateways. One long-lived WebSocket or SSE connection per online device, ~10M at peak. A connection registry says which gateway holds which user; in-app delivery sends that gateway a small "seq 1184 is ready" hint. Lose the hint and the client resumes from the inbox: the socket is a fast path, never the record.
- Presence. Who's online, as soft state: heartbeats with an expiry, so a dead phone goes offline by itself. Chat and presence reuse the gateways.
Around them: templates rendered per channel and language, provider callbacks (receipts, bounces, complaints) by webhook, and an audit store, an append-only log in a columnar store like ClickHouse.
4.2 One Notification, End to End#
Follow Arjun's ₹2,400 with the platform in place. Priya's web app is open; her phone is in her bag.
%% title: Payment received: from the payment service to Priya's screen sequenceDiagram autonumber participant PS as Payment service participant API as Send API participant Q as Outbox and lanes participant RT as Router participant PF as Preferences participant IA as In-app delivery participant GW as Gateway gw-17 participant B as Priya's browser participant PW as Push worker PS->>API: send(priya, payment_received, 2400 from Arjun), key txn-9f3 API->>Q: notification row and outbox row, one transaction API-->>PS: 202 Accepted, notification n-81 Q->>RT: transactional lane RT->>PF: preferences and devices for priya PF-->>RT: in-app on, push on, web session live RT->>IA: in-app task IA->>IA: inbox append, seq 1184 IA->>GW: hint for priya, seq 1184 GW-->>B: seq 1184, 2400 rupees received from Arjun B->>GW: ack 1184 Note over IA,PW: acked within 5 s, so no push for a routine payment Note over RT,PW: no live device or no ack, then push via APNs or FCM
In a typical design the live path takes a few hundred milliseconds: two queue hops, two cache reads, an inbox write and a socket write. Two details carry the week:
- The inbox write comes before the socket push. If
gw-17crashes between steps 9 and 10, Priya's browser reconnects elsewhere and asks for "everything after 1183". Nothing is lost. - Push depends on the ack. No ack within a few seconds means the socket was probably dead, so the platform falls back to push (Day 3).
4.3 The Router's Policy, in Code#
The router turns Chapter 2's class table into behaviour. A sketch of its core decision (Day 2 builds the real one):
# pipeline/plan.py: which channels, in which order (illustrative)
from datetime import datetime
POLICY = {
# class: (lane, send first, then, in order, on failure or no ack)
"critical": ("critical", ["push"], ["sms", "email"]), # alerts: unread after 2 min, SMS (Day 2)
"transactional": ("transactional", ["in_app"], ["push", "email"]),
"social": ("transactional", ["in_app"], ["push"]),
"reminder": ("transactional", ["push"], ["email"]),
"marketing": ("bulk", ["push"], ["email"]),
}
def plan(n, prefs, now: datetime) -> dict:
lane, first, fallbacks = POLICY[n.cls]
if n.category == "otp":
first, fallbacks = ["sms"], [] # a code is useless late: SMS at once, no ladder
if n.cls == "transactional" and prefs.sms_alerts_for(n.category):
fallbacks = fallbacks + ["sms"] # the user asked for SMS on payments
def ok(channel): # critical alerts can't be opted out of
return n.cls == "critical" or prefs.allows(n.category, channel)
send_at = now
if n.cls in ("reminder", "marketing") and prefs.in_quiet_hours(now):
send_at = prefs.quiet_hours_end(now) # defer, don't drop
return {
"lane": lane,
"now": [c for c in first if ok(c)],
"fallback": [c for c in fallbacks if ok(c)], # in-app with no live socket counts as "no ack"
"send_at": send_at,
}Four choices to notice: critical traffic ignores opt-outs and quiet hours (a 2 am fraud alert is the point); quiet hours defer rather than drop; fallbacks run only when the first channel fails or goes unacknowledged, which is what keeps SMS at 5%; and an OTP skips the ladder, going by SMS at once, because a code that arrives late is useless. A security or fraud alert starts with push and escalates to SMS if it's still unread after 2 minutes (Day 2's escalation saga).
The router turns a notification's class into a channel plan, then filters it by preferences and quiet hours. A payment goes in-app first, then push only if no device acks within a few seconds, then email (SMS only if the user asked for it); a marketing push at 23:00 is deferred to the end of quiet hours at 08:00, not dropped; a fraud alert ignores opt-outs and quiet hours and goes by push, then SMS if still unread after 2 minutes, then email; an OTP skips the ladder and goes by SMS at once, with a 120-second validity period.
What breaks if preferences are checked once?
A bill reminder is accepted at 18:00 for 09:00 tomorrow. The router checks preferences on arrival, finds reminders enabled, and stores the planned channels with the task. At 23:00 the user turns reminders off. What happens at 09:00, and where should the check be?
One strong answer
- It goes out anyway: decided at 18:00 against preferences that no longer hold. Meera's campaign, smaller.
- Check again at send time. Plan early if you like, but re-read preferences just before handing to a provider: consent changes while notifications wait.
- The cost is one cached read per send. Day 2 covers this, including the race when the change and the send land in the same second.
Chapter 5: The Data Model#
5.1 Five Records#
Most of the platform's correctness lives in how these records are separated:
%% title: The data model: what is stored, and how the records relate
flowchart TD
U["user<br/>user_id · locale · timezone · phone · email"]:::core
P@{ icon: "sd:database", label: "preferences<br/>user_id · category · channel · enabled<br/>quiet hours · consent log" }
D@{ icon: "sd:phone", label: "device_tokens<br/>token · platform · status · last_seen" }
N@{ icon: "sd:bell", label: "notifications<br/>notification_id · user_id · class<br/>template · variables · idempotency_key" }
DL@{ icon: "sd:log", label: "deliveries<br/>notification_id · channel · provider_msg_id<br/>status · attempts · error" }
I@{ icon: "sd:feed", label: "inbox_items<br/>user_id · seq · notification_id<br/>title · body · read_at" }
U -->|"1 to many, one per category and channel"| P
U -->|"1 to many"| D
U -->|"1 to many"| N
N -->|"1 to many, one per channel"| DL
N -->|"1 to 0 or 1"| I
class P,D,N,DL,I storenotifications: the logical event, one per user per event ("Priya received ₹2,400", n-81), holding the idempotency key so a retry finds the existing row.deliveries: one per channel attempt. A fraud alert sent by push and then, still unread, by SMS is one notification with two deliveries, each with its provider message id and status, so "did the user get it?" and "did the SMS fail?" are separate questions.inbox_items: what the bell shows, keyed by(user_id, seq). The sequence number gives the order, makes "everything after 1183" a range read and makes gaps visible. Read state lives here.preferences: one row per(user, category, channel), plus quiet hours, timezone and a consent log. The switch says what to do; the log proves you were allowed to.device_tokens: one per app install, with a status and last-seen time, because tokens change on reinstall and providers report dead ones.
A condensed schema, trimmed to what the design needs:
CREATE TABLE notifications (
notification_id UUID NOT NULL,
user_id UUID NOT NULL,
class TEXT NOT NULL, -- critical | transactional | social | reminder | marketing
category TEXT NOT NULL, -- e.g. otp, payments, security, offers
template TEXT NOT NULL,
variables JSONB,
idempotency_key TEXT NOT NULL, -- from the caller: same key, same notification
send_at TIMESTAMPTZ, -- null = now
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (notification_id, created_at)
) PARTITION BY RANGE (created_at); -- one partition a day or a month; drop old ones whole
-- dedup is checked within the retention window by a unique index per partition or a key store
CREATE TABLE deliveries (
notification_id UUID NOT NULL,
channel TEXT NOT NULL, -- push | sms | email | in_app
target TEXT NOT NULL, -- token id, phone, address; user id for in-app
provider_msg_id TEXT, -- what the provider calls it, for callbacks
status TEXT NOT NULL, -- queued | sent | delivered | failed | bounced | read
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (notification_id, channel, target)
);
CREATE TABLE inbox_items ( -- partitioned by user_id in production
user_id UUID NOT NULL,
seq BIGINT NOT NULL, -- per-user, strictly increasing
notification_id UUID NOT NULL,
title TEXT NOT NULL,
body TEXT,
deep_link TEXT,
created_at TIMESTAMPTZ NOT NULL,
read_at TIMESTAMPTZ,
PRIMARY KEY (user_id, seq)
);
CREATE TABLE preferences (
user_id UUID NOT NULL,
category TEXT NOT NULL,
channel TEXT NOT NULL,
enabled BOOLEAN NOT NULL,
version INT NOT NULL DEFAULT 1, -- optimistic locking for concurrent edits
PRIMARY KEY (user_id, category, channel)
);
CREATE TABLE device_tokens (
token_id UUID PRIMARY KEY,
user_id UUID NOT NULL,
platform TEXT NOT NULL, -- ios | android | web
token TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',-- active | invalid
last_seen TIMESTAMPTZ NOT NULL
);5.2 Where Each Record Lives#
| Record | Store (a typical choice) | Partition key | Why |
|---|---|---|---|
| Preferences, device tokens | PostgreSQL, Redis cache in front | user_id |
Small, transactional, read on every send |
| Notifications, deliveries | PostgreSQL partitioned by time, or a wide-column store | time, then id | ~18 TB; old partitions dropped whole |
| Inbox | Wide-column (Cassandra-style) or PostgreSQL sharded by user | user_id, clustered by seq |
"Newest 20" and "after seq N" are single-partition reads (Week 1, Day 1 · Partitioning) |
| Audit and analytics | Columnar (ClickHouse) | time | Append-only, scanned in bulk |
| Registry, presence, unread counts | Redis, in memory | user_id |
Soft state, rebuilt by heartbeats |
The inbox in a Redis list
An earlier version of this design kept the in-app inbox in Redis: LPUSH inbox:{user}, LTRIM to the newest 100, and the socket push at the same time. Name three things that break.
One strong answer
- Loss: a failover that drops recent writes, or an eviction under memory pressure, deletes notifications that existed nowhere else.
- No resume: a list position isn't a sequence number, so a reconnecting client can't ask "everything after 1183", and gaps are invisible.
- The cap: item 101 vanishes with its read state. Fix: a durable inbox keyed by
(user_id, seq), Redis only caching the unread count.
Sent is not delivered
A fraud alert goes to Priya's phone by push. FCM accepts it and returns a message id; the phone has been off in a drawer for three days. What status should the deliveries row show, and does it count as delivered for the 99.9% target?
One strong answer
sent, notdelivered. FCM counts a send when the message "has been enqueued for delivery", not when it reaches the app (Firebase docs).- It doesn't count as delivered. Receipt comes later, from the app or FCM's delivery data, and here it never comes.
- So push alone can't carry the target, which is why a critical alert still unread after 2 minutes escalates to SMS (Day 2). Day 5 turns this into measurable SLOs.
Chapter 6: Channel Trade-Offs#
6.1 Cost, Latency, Reach and Reliability#
Each channel wins on something and loses on something else. Provider figures are as of October 2026.
| Push (APNs, FCM) | SMS | In-app (our gateways) | ||
|---|---|---|---|---|
| Cost per message | FCM: "No-cost" (Firebase pricing) | Per segment. Twilio list: $0.0083 + a carrier fee in the US, $0.0832 to India; local registered routes far cheaper | SES: $0.10 per 1,000 (AWS pricing) | Our gateways' compute |
| Latency | Usually seconds; provider may delay | Seconds, carrier-dependent | Seconds to minutes | Under a second |
| Reach | Only users who allow it: on Android 13+ a new app's "notifications are off by default" until granted (Android docs) | Any phone: no app, no data, no permission | Anyone with an address, if not filtered | Only users with the app open now |
| What success means | Accepted: FCM's "Sends" means enqueued, not received (Firebase docs) | A carrier delivery receipt, varying by country | Accepted; webhooks later: delivered, bounced | The client acked the seq |
| Limits | FCM: 600,000 a minute per project by default; 240 a minute to one Android device (Firebase docs) | 160 characters per segment in GSM-7, 70 in UCS-2; one non-GSM character switches the whole message (Twilio docs), and "₹" isn't in GSM-7 | Sender rules (next row) | Connections, memory, reconnect rate |
| Rules | The user controls permission | India: use case and templates registered with TRAI via DLT for local routes (AWS docs) | Gmail, senders of 5,000+ a day to Gmail accounts since February 2024: SPF, DKIM, DMARC, "one-click unsubscribe", spam rate below 0.30% (Google) | Ours |
The pattern behind the table:
- Push is free and fast, but no permission, no push. It's a doorbell, not a mailbox; the app reads the inbox when it opens.
- SMS reaches nearly everyone with no app, hence OTPs, and a template bug or retry loop costs real money.
- Email is cheap and rich but slow to be noticed and bound to sender reputation: receipts, statements, digests.
- In-app is fastest, but reaches only people looking at the app, and every connection costs us even when idle.
No channel is "best". The platform picks per class and per user the cheapest channel that will reach the person in time, and escalates only when it doesn't.
Pick the channels, three times
Choose channels for: (1) a login OTP for Rohan on a new phone where the app isn't logged in; (2) "₹2,400 received" for Priya, web app open; (3) a cashback offer to 10M users at 20:00.
One strong answer
- OTP: SMS (plus push to another logged-in device, if any). The new phone has no session or push permission yet; SMS needs no app state. Worth its price.
- Payment: in-app over the live connection, push only if no ack arrives within seconds. She's looking.
- Cashback: push and an in-app banner, never SMS, paced into the bulk lane after preferences and quiet hours; email for users without push permission.
6.2 The Rules Belong to the Platform#
Compliance is a strong argument for one platform: each rule gets a single enforcement point.
- Consent per channel, with a record. Consent to marketing email isn't consent to marketing SMS; the consent log in 5.1 records when and how each was given.
- Unsubscribe that works at once. Gmail requires "one-click unsubscribe" for bulk senders' marketing (Google), and it must reach the router's cache in seconds.
- Registered SMS templates. In India a local-route SMS carries an Entity ID and a Template ID from DLT registration (AWS docs), so a new SMS template ships only once it's registered.
- An audit trail. "Every notification we sent this user in March, and what the provider said" must be a query, not an investigation.
Part III: The Week Ahead#
Chapter 7: Which Day Goes Deep on What#
Today drew every box. Each of the next four days takes some of them and makes them work under failure and at scale.
%% title: Week 6: which day builds which part of the platform flowchart TD D1["Day 1 · The whole system<br/>requirements, estimates, architecture, data model"]:::hot D2["Day 2 · The delivery pipeline<br/>guarantees, priority lanes, preferences"]:::core D3["Day 3 · Real-time channels<br/>gateways, WebSockets and SSE, resume"]:::core D4["Day 4 · Presence and chat fan-out<br/>online dots, typing, groups, receipts"]:::core D5["Day 5 · Operations, scale, edge cases<br/>SLOs, campaigns, provider failover, cost"]:::core D1 --> D2 D2 -->|"in-app tasks and the inbox"| D3 D3 -->|"gateways and the registry"| D4 D2 --> D5 D3 --> D5 D4 --> D5
| Piece of today's diagram | The hard question | Day |
|---|---|---|
| Ingest, outbox, lanes, router, channel workers | Never lose a notification, never visibly send it twice, never let a campaign delay an OTP; what does a preference change do to queued messages? | Day 2 |
| Inbox, gateways, connection registry | Keep 10M sockets alive through network changes, deploys and crashes, and find the right one | Day 3 |
| Presence, chat fan-out | Show who's online without flooding everyone; send one message to a 300,000-member group | Day 4 |
| The platform in production | What to measure and promise; surviving campaign bursts, provider outages and lost regions; cost | Day 5 |
The Preface maps the same way: the late OTP and Meera's ignored opt-out are Day 2, Priya's bell is Day 3, Kabir's green dot is Day 4, the SMS bill and the 20:00 burst are Day 5.
Part IV: Real-World Application#
Chapter 8: How Real Companies Do It#
8.1 LinkedIn: Air Traffic Controller#
LinkedIn built Air Traffic Controller (ATC) so teams stopped deciding notifications on their own. Its goal: "the right message to the right member through the right channel at the right time with the right frequency". ATC drops duplicates, rate-limits upstream applications, chooses the channel, aggregates related notifications and picks a good time to send. It runs as a stream processor on Apache Samza, with Kafka and local RocksDB state. It "currently processes over a billion requests per day", it let LinkedIn "cut member complaints in half", and parallelising it cut P90 latency for messaging push notifications "from about 12 seconds to about 1.5 seconds" (LinkedIn Engineering, March 2018).
What carries over: centralise the decision (whether, which channel, when), not only the sending. Complaints fell because of what ATC chose not to send.
8.2 Uber: RAMEN, a Real-Time Push Platform#
Uber's apps used to poll for state. "At some point, 80% of requests made to the backend API gateway were polling calls" (Uber Engineering, December 2020). Their replacement splits the problem in three: Fireball decides "when to push a message?", the API gateway decides what to push by calling domain services for the payload, and RAMEN servers hold the persistent connections and deliver.
RAMEN began on Server-Sent Events with sequence-number acknowledgements so clients could resume; a next-generation protocol on gRPC bidirectional streaming was "currently in beta release" when the post was written. Messages carry a priority (High, Medium, Low) and a time-to-live "ranging from a few seconds to up to 30 minutes"; servers "hold all the messages either in memory and backed in a database" and retry until the TTL expires. Uber reports "more than 1.5M concurrent connections" and "over 250,000 messages per second" (Uber Engineering, 2020).
What carries over: separate when, what and how; give each message a sequence number and a TTL; keep it durable until acknowledged. That's our router, templates, inbox and Day 3's gateways.
8.3 Slack: Writes In Over HTTP, Events Out Over Sockets#
"Every Slack client has a persistent websocket connection to Slack's servers to receive real-time events to maintain its state", but to send a message "the client hits our Webapp API", and the message is fanned out to subscribed clients from there. Slack reports "tens of millions of connected clients" and delivery "across the world in 500ms" (Slack Engineering, April 2023). What carries over: durable write through an ordinary API, delivery over the socket, so a dropped socket loses nothing.
Chapter 9: Common Mistakes#
- Designing only the send path. API to Twilio, and nothing for the user with the app open. Include the inbox and the real-time path; the inbox is what makes the real-time path safe.
- One queue for every class. That's the Preface's 20:08 OTP. Separate lanes and workers from day one.
- Treating a provider's 200 as delivery. FCM's "sent" means enqueued; an accepted email can bounce an hour later. Keep
sentanddeliveredapart and measure the gap. - SMS as the safety net for everything. 5% of messages, ~90% of the bill. Use it where reaching the person is worth the price, after cheaper channels fail.
- The socket as the record. Every crashed gateway, dropped Wi-Fi and deploy then loses messages. Inbox first, then the hint.
Part V: Interview Preparation#
Chapter 10: Interview Tips#
10.1 When to Bring It Up#
Either the prompt says "notification system", or it hides inside another one: "tell the user when the order ships", "riders get a trip offer", "show unread counts". Whenever a design sends anything to users, one sentence ("this goes through a notification platform with priority lanes and an inbox") shows you know it's a system of its own.
10.2 Key Phrases#
FRAMING
"Two halves: store-and-forward through providers for users who aren't looking, and a real-time tier with an inbox for users who are."
CLASSES
"Notifications aren't one thing. OTPs, payments and campaigns have different clocks, so they get different lanes, and marketing can never delay a transaction."
COST
"SMS is about 5% of the volume and almost all of the bill, so it's reserved for OTPs, critical alerts and real push failures."
RELIABILITY
"Provider-accepted isn't delivered. I track a status per channel attempt, OTPs go by SMS at once, and a security alert nobody has read in two minutes escalates to SMS."
10.3 Questions to Ask the Interviewer#
- "Which notifications are critical, and what's their latency target?"
- "Do online users need to see things live, or is the next refresh fine?"
- "Is there an SMS budget, and which countries do we send to?"
- "How big are campaigns, and how often?"
10.4 Common Follow-Up Questions#
| Question | Good answer |
|---|---|
| "Why not call the provider from the API?" | "Providers are slow and fail. The API persists and returns 202; workers deliver with retries and fallback." |
| "How do you avoid sending twice?" | "Idempotency key at ingest, dedup in the router: at-least-once with dedup, since exactly-once isn't available." |
| "A user opts out while a campaign is queued?" | "The worker re-checks preferences just before sending, from a cache the opt-out invalidates." |
| "How do you know a push arrived?" | "Providers confirm acceptance; receipt comes later from the app or delivery data. A critical alert still unread after 2 minutes escalates to SMS." |
| "Why an inbox if you have push?" | "Push is a doorbell with little offline storage and no ordering. The inbox is the record clients read and resume from." |
Chapter 11: Practice Problems#
Practice · problem 1
Problem 1: A Ride-Hailing App's Notifications#
Setup: 20M riders, 2M drivers. Trip offers must reach drivers within 2 seconds and be accepted within 15. Classify the notifications, pick channels, and say what the real-time path carries.
Click for solution outline
- Classes: trip offers are critical with a hard TTL (an offer at 20 s is worse than none); "driver arriving" is transactional; receipts are email; promotions are bulk.
- Channels: drivers keep the app in the foreground, so offers go over the live connection with push as fallback.
- Real-time path: offers, trip state and location coalesced to the latest point, each with a TTL, as Uber's RAMEN does.
Practice · problem 2
Problem 2: Halve the SMS Bill#
Setup: Using today's calc ($3.0M a month for 750M SMS), the CFO wants it halved without hurting OTP delivery. Where do you look first?
Click for solution outline
- Record a reason for every SMS (OTP, critical, push-failure fallback, user-requested, marketing) and look at the split.
- Cut marketing SMS entirely; move it to push, in-app and email.
- Fall back to SMS only on a real push failure (invalid token, no device), not on a slow ack.
- Check segments: "₹" or an emoji makes a message UCS-2, 70 characters a segment. Day 5 works the numbers.
Practice · problem 3
Problem 3: The Data Model for a Bell With Read State#
Setup: An unread badge correct on every device within a second of reading anywhere, plus "mark all as read". Design the records and the update path.
Click for solution outline
- Inbox items keyed by
(user_id, seq)plus a per-user read cursor ("read up to seq N"), withread_atfor items read out of order. - "Mark all as read" is one cursor write, not thousands of row updates.
- The unread count is derived from the latest seq and the cursor, cached in Redis; a read sends a "cursor moved" hint to the user's other connections.
Summary#
THE PROBLEM
- One platform deciding whether, how and when to tell a user; store-and-forward through providers plus a real-time tier
THE NUMBERS
- 500M a day ≈ 5,800/s, ~17,400 at peak, design for 20,000; SMS is 5% of messages, ~90% of the bill; ~10M live connections
THE ARCHITECTURE
- Ingest (idempotency key, outbox) → priority lanes → router → channel workers → providers
- In-app: inbox first with a per-user seq, then a hint through the gateways; presence as expiring soft state
DATA AND CHANNELS
- Notification ≠ delivery ≠ inbox item; preferences with a consent log; tokens with a status
- Push: free, conditional reach. SMS: universal, expensive. Email: cheap, slow. In-app: fastest, online users only
Further Reading#
Engineering Posts#
- Blog
LinkedIn — Air Traffic Controller: Member-First Notifications at LinkedIn (1 March 2018): https://www.linkedin.com/blog/engineering/messaging-notifications/air-traffic-controller-member-first-notifications-at-linkedin
- Blog
Uber — Uber's Real-Time Push Platform (December 2020): https://www.uber.com/blog/real-time-push-platform/
- Blog
Slack — Real-time Messaging (11 April 2023): https://slack.engineering/real-time-messaging/
Provider Documentation#
- Read
Firebase — Pricing (FCM "No-cost"): https://firebase.google.com/pricing
- Docs
Firebase — Throttling and quotas: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas
- Docs
Firebase — Understanding message delivery: https://firebase.google.com/docs/cloud-messaging/understand-delivery
- Read
Android — Notification runtime permission: https://developer.android.com/develop/ui/views/notifications/notification-permission
- Read
Amazon SES — Pricing: https://aws.amazon.com/ses/pricing/
- Read
Twilio — SMS pricing, United States and India: https://www.twilio.com/en-us/sms/pricing/us · https://www.twilio.com/en-us/sms/pricing/in
- Docs
Twilio — SMS character limits and segments: https://www.twilio.com/docs/glossary/what-sms-character-limit
- Docs
AWS End User Messaging — India sender ID registration and routes: https://docs.aws.amazon.com/sms-voice/latest/userguide/registrations-sms-senderid-india.html · https://docs.aws.amazon.com/sms-voice/latest/userguide/registrations-sms-senderid-india-routes.html
- Docs
Google — Email sender guidelines: https://support.google.com/a/answer/81126
this lesson in shorts · 4
play them all →One idea from this lesson in about a minute each, made for your phone.
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
Air Traffic Controller: Member-First Notifications at LinkedIn
Changji Shi, Adriel Fuad · 2018- What
- How LinkedIn put one service in charge of every notification sent to members, across email, SMS, push and in-app.
- Key idea
- ATC, built on Samza with state in RocksDB, dedups requests, rate-limits upstream teams and picks the channel and time for each member, handling over a billion requests a day.
- Why here
- Why this lesson centralises the decision (whether, which channel, when) and not just the sending: complaints fell because of what ATC chose not to send.
Uber's Real-Time Push Platform
Madan Thangavelu, Anirudh Raja et al. · 2020- What
- How Uber replaced app polling with RAMEN, a push platform holding long-lived connections to its rider and driver apps.
- Key idea
- Fireball decides when to push, the API gateway builds the payload and RAMEN servers deliver, first over SSE and later gRPC, at 1.5M connections and 250,000 messages a second.
- Why here
- The real-time half of this lesson's two-halves platform: separate when, what and how, and give each message a sequence number so clients can resume.
Rapid Event Notification System at Netflix
Ankush Gulati, David Gevorkyan · 2022- What
- The design of RENO, Netflix's system for telling member devices about events such as a profile change.
- Key idea
- Events are routed to priority-specific queues and processing clusters, stale events are dropped by age, and only devices currently online are notified, using a registry kept by Zuul.
- Why here
- A complete design to compare with this lesson's architecture: priority lanes, an expiry on every message and a registry of who is online.
netflixtechblog.com answers automated fetchers with a Cloudflare 403; verified through an Internet Archive snapshot of the post (January 2025).
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.



