?

Day 0130 minPreface+60 XP

Designing a Real-Time Notification Platform.

5 stages2 code

watch it first · newThe whole lesson in a 22-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 2 min

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.)

ONE EVENING, FIVE FAILURES (ILLUSTRATIVE)
  • 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.

ExplainerThe whole lesson: designing a real-time notification platform

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 · Stage 1 of 5 · 8 min

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.

Every team sends its own vs one platform

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:

FERNS: five areas to clarify before drawing
  • 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#

Transcript
You

"What kind of product is this, and what do we notify people about?"

Interviewer

"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."

You

"When the app is open, should a notification appear live, or on the next refresh?"

Interviewer

"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."

You

"What do users control, and what's the scale?"

Interviewer

"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."

You

"What are the latency requirements?"

Interviewer

"For transaction notifications, within 1–2 seconds. Security alerts should be near-instant. Marketing can be slightly delayed: within a minute is fine."

You

"And reliability?"

Interviewer

"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."

You

"If resources are tight, what wins?"

Interviewer

"Transaction and security notifications should never be delayed by marketing campaigns."

You

"Cost or regulation?"

Interviewer

"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.

Requirements summary
Functional
  1. Accept requests from any service, with templates and per-user variables; schedule and run campaigns
  2. Channels: push (APNs, FCM), SMS, email, in-app (a bell with an unread count)
  3. Real time: online users see notifications and chat live; group chats show presence
  4. Preferences: per category and channel, quiet hours, unsubscribe; critical can't be disabled
  5. Inbox with read state, a status per channel attempt, and an audit trail
Non-functional
  1. Scale: 50M users, 500M a day, 10M an hour in campaigns, ~15% online at peak
  2. Latency: security near-instant, transactions 1–2 s, live in-app under 1 s, marketing within a minute
  3. Reliability: 99.9% for critical; at-least-once, no visible duplicates; marketing never delays transactions
  4. 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.

Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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.

Your turn · checkpoint 2not answered

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?

Think it through, or jot it down. Then compare.

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#

WorksheetDaily volume by channel and the provider bill (inputs assumed unless a source is named)
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 · Stage 2 of 5 · 11 min

Part II: The Architecture#

Chapter 4: The Whole System#

4.1 The Pieces#

DiagramThe real-time notification platform: every piece this week builds
%% 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 client

Seven pieces, each with one job:

  1. 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 returns 202 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

DiagramPayment received: from the payment service to Priya's screen
%% 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-17 crashes 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):

pythonplan · 31 lines
# 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).

ExplainerChannel selection: class policy, preferences, quiet hours

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.

Your turn · checkpoint 3not answered

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?

Think it through, or jot it down. Then compare.

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:

DiagramThe data model: what is stored, and how the records relate
%% 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 store
  • notifications: 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:

sqlTEXT · 55 lines
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
Your turn · checkpoint 4not answered

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.

Think it through, or jot it down. Then compare.

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.
Your turn · checkpoint 5not answered

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?

Think it through, or jot it down. Then compare.

One strong answer

  • sent, not delivered. 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 Email 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.

Your turn · checkpoint 6not answered

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.

Think it through, or jot it down. Then compare.

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 · Stage 3 of 5 · 1 min

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.

DiagramWeek 6: which day builds which part of the platform
%% 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 · Stage 4 of 5 · 3 min

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#

  1. 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.
  2. One queue for every class. That's the Preface's 20:08 OTP. Separate lanes and workers from day one.
  3. Treating a provider's 200 as delivery. FCM's "sent" means enqueued; an accepted email can bounce an hour later. Keep sent and delivered apart and measure the gap.
  4. 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.
  5. The socket as the record. Every crashed gateway, dropped Wi-Fi and deploy then loses messages. Inbox first, then the hint.

Part V · Stage 5 of 5 · 5 min

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.

❯ Say this

10.2 Key Phrases#

Phrases that show you've thought it through
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"), with read_at for 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#

DAY 1 KEY TAKEAWAYS
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#

Provider Documentation#

this lesson in shorts · 4

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 01 · Designing a Real-Time Notification Platform
Finish Day 1Stages clear as you read them (0/5 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

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.

LinkedInBlog post · ~12 min

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.
taught here: One notification platform
UberBlog post · ~19 min

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.
taught here: The real-time tier
NetflixBlog post · ~8 min

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.
taught here: Priority lanes and online devices

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…