Notification System
Accept in milliseconds, deliver through vendors that lie, and never send the same thing twice.
“Design a notification system.”
Nothing gets drawn until these are answered. Each one moves a box on the board or removes it.
- 01Which channels are in scope — in-app, push, email, text — and which one are we contractually worst at losing?
- 02Transactional, marketing, or both under one system? They disagree about consent, about ordering, and about who is allowed to be late.
- 03Who triggers a send: our own services, or tenants calling an API? That decides authorization, quotas, and how hostile the input is.
- 04Is latency or ordering contractual for any type? A one-time code is a promise. A weekly digest is not.
- 05Do we own templating and localization, or does the vendor render for us?
- 06What does delivered mean for reporting — handed to a vendor, accepted by a device, or seen by a person?
- 07How long do we have to be able to answer what we sent someone and why?
- 08Steady load or campaign spikes, and does one request ever have to reach every user at once?
Arrow keys pan the board, plus and minus zoom it, zero frames it on this step again.
- client
- service
- cache
- store
- queue
- worker
- third party
- async
- 01
Pin the contract before drawing a box
Before I draw anything, I want one entry point and one definition of what a notification is.
A notification is a request to inform one recipient about one event — it is not a message on a wire and not an email. The request carries a tenant, a recipient, a type, the variables the template needs, and an idempotency key. It does not carry a channel: channels are a resolution result, and if callers pick them, every caller reimplements preferences and one of them gets it wrong. It also does not carry an address — the email address, the phone number, and the device tokens come from the existing profile service at send time, and I am drawing that as a dependency rather than rebuilding it here.
- 02
Make the accept path fast and durable
The write path does four things — validate, claim the key, write the row, ack — and nothing else.
The caller is usually inside somebody's request, so accept has to return an id in single-digit milliseconds. The idempotency key is claimed first, so a retried call returns the original id rather than sending twice. The cache is the fast path and not the guarantee: the same key is a unique index on the row, so a flushed cache turns a retry into an insert conflict that hands back the original id instead of a second notification. Then the row is written — the record of intent, committed before anything downstream can see it — which is what makes it possible to answer later why a person got this.
- 03
Put a durable log between accepting and sending
Accepting is fast and sending is slow, so a log goes between them and the two stop caring about each other.
Delivery is remote, slow, and allowed to fail. Accepting is none of those things and must not become any of them. Publishing after the row commits is what makes the log a replayable ledger rather than a second source of truth — but committing and then publishing is a dual write, and a crash in between leaves an id handed to the caller and nothing on the log. So the row and an outbox record commit in one transaction and a relay tails the outbox onto the log: publishing becomes at-least-once, and the dedupe claim downstream absorbs the repeat. Partitioning by tenant keeps a noisy tenant inside its own partitions, and retaining a few days means a bad consumer deploy is a rewind, not an incident report.
- 04
Resolve preferences at send time, not at enqueue time
Preferences get read here, at the last moment, not back at the API.
A message can sit in the log for seconds, or during an incident for hours, so a preference read taken at accept time is a stale read by the time it matters. The dispatcher reads the per-type channel matrix, the global unsubscribe, and the suppression list, and turns one notification into zero or more channel sends. Zero is a legitimate answer, and it gets recorded as suppressed with a reason — silently dropping it is how you lose an audit.
- 05
Fan out into one queue per channel
Each channel gets its own queue and its own workers, so they are allowed to fail separately.
Channels have nothing in common operationally: push is cheap and fast, email is slow and bursty, text is metered and costs real money per message. On a shared queue the head of the line is whatever is slowest, so one vendor having a bad afternoon stalls every other channel behind it. Separate queues let concurrency, retry policy, and drain rate be tuned per channel — a text queue that drains at twenty a second is a budget control, not a bug. Each channel is split again into an urgent lane and a bulk lane with its own drain ceiling, because the resource actually worth protecting is vendor throughput, and that is where one tenant's campaign would otherwise sit in front of everyone else's one-time codes. The senders render here rather than at the vendor: one template store, versioned and localized, read on the send path, which is what lets a channel move to a different vendor in an afternoon.
- 06
Treat every provider as hostile
I assume the vendor is slow, down, or lying about success, and make the worker survive all three.
Each sender wraps the call in a timeout, retries timeouts and server errors with exponential backoff and full jitter, and never retries a hard rejection — a bad address does not improve on the fourth attempt. A breaker per vendor opens on a sustained error rate so we stop paying latency to something already down, and the queue holds the backlog while it is open. Work that outlives its retry budget goes to a dead-letter queue with the failure attached, because that is where the interesting bugs live and deleting it is how you find out three days later.
- 07
At-least-once plus a dedupe claim, not exactly-once
I'm not chasing exactly-once — I'm making a second delivery cheap to detect and cheap to drop.
Every hop here can redeliver: the log on a rebalance, the queue on a lost ack, the sender on a timeout it cannot interpret. So before handing work to a channel the dispatcher claims a key on the identity of this notification and this channel — the caller's idempotency key when there is one, a hash of tenant, event id, recipient and type when there is not. It has to be per-event, not per-type: a key of tenant, recipient, type and window would collide two genuinely different comment alerts and drop the second as a duplicate. That tuple is the rate-limit counter, which is a different mechanism living in the same store. The claim is a set-if-absent with a TTL longer than the retry budget, so the redelivered copy loses the race and exits quietly. It bounds fan-out duplicates only; the last hop is bounded separately, by the sender passing a vendor-side idempotency key on the request. Exactly-once dies there anyway — the vendor can accept a message and lose it, and no protocol on our side fixes that.
- 08
Rate limits, quiet hours, and digests
This layer is the reason people keep the app installed.
The same short-lived store holds per-user, per-type send counters and the recipient's quiet-hour window, evaluated in their time zone, both read on the dispatch path. Anything over the limit or inside quiet hours is held in the digest buffer instead of sent. A scheduled sweep on the dispatcher's own deployment rolls the held items up, writes its own notification row and idempotency claim exactly as the API would, and publishes that to the log — a digest is a notification like any other, so it inherits preferences, dedupe, and an audit trail rather than appearing downstream as a delivery with no parent. Urgent types — one-time codes, password changes, failed payments — carry a bypass and are never held, because a code that arrives an hour late is a broken login, not a polite one.
- 09
The inbox is a read problem, not a send problem
The in-app inbox is a different system with different pressure, so I draw it as one.
It is written on fan-out — one row per recipient keyed by user and time — so a page of the feed is one index scan and never a join across events. It hangs off the log rather than off preference resolution, because the inbox is the record of what happened to you: muting a type turns off the push, not the history. Unread counts are the expensive part, read on every app open, so the badge is a maintained counter that is rebuilt from the rows when it drifts. Reads outrun writes by an order of magnitude here, and a badge that is slow is worse than a badge that is briefly approximate.
- 10
Close the loop with vendor callbacks
A vendor accepting a message is not a delivery, so the last thing I draw is the path that tells me the truth.
Vendors report asynchronously — delivered, bounced, complained, unsubscribed — into an endpoint that verifies the signature, writes nothing synchronously, and publishes the event, so a callback storm hits a queue instead of the database. A worker folds those outcomes onto the per-attempt rows and writes hard bounces and complaints back into suppression — that stroke is the loop closing, because suppression is the same list the dispatcher reads, so an unsubscribe honors itself on the very next send. An invalid device token is the push equivalent of a hard bounce and takes the same path: the token is deleted rather than suppressed, or we spend forever pushing at a phone that was wiped. The four numbers I would page on: accept latency, consumer lag per channel, send success rate per vendor, and dead-letter arrival rate. Everything else is a dashboard someone looks at on purpose.
Step 1 of 10. Pin the contract before drawing a box. Before I draw anything, I want one entry point and one definition of what a notification is.
Functional
- 01Accept a request to notify one recipient about one event, and resolve it into channel sends.
- 02Per-user, per-type preferences: opt in and out per channel, plus a global unsubscribe that outranks everything.
- 03Templating and localization owned by us: one template per type per channel, rendered against payload variables in the recipient's locale.
- 04Quiet hours and digests: hold the non-urgent types and roll them up instead of sending at 3am.
- 05An in-app inbox: a per-user feed with read state and an unread count on every app open.
- 06Delivery status per attempt, queryable by support: what we sent, to which address, when, and what the vendor said back.
- 07Audit and retention: every send and every suppression stays explainable for the retention window.
Non-functional
- 01Accept in single-digit milliseconds and lose nothing once acked — the caller is usually inside a user request.
- 02At-least-once delivery with a dedupe claim. A duplicate is a bug, not a shrug, but it is a bounded one.
- 03Provider isolation: one flaky vendor degrades one channel and nothing else.
- 04Multi-tenant fairness: no tenant, and no single campaign, can starve another tenant's transactional traffic.
- 05Compliance is not a feature. An honored unsubscribe is a legal obligation with a deadline, and suppression has to survive a cache flush.
- 06Absorb a 5x peak and broadcast bursts by going slower on the non-urgent types, not by dropping them.
- 07Observable per channel and per vendor: lag, success rate, and dead-letter arrivals, each with a threshold that pages.
Do it out loud, round hard, and say the assumption you rounded from. The number matters less than the fact that it was derived.
- Daily actives
- 20M
- The only number I am guessing. Everything below is derived from it, so if you tell me it is 200M the shape holds and the boxes get wider.
- Notifications accepted
- 100M / day
- 20M users x 5 notifications per user per day = 100M requests a day.
- Accept rate
- ~1.2K/s avg · ~6K/s peak
- 100M ÷ 86,400s = 1,157/s. Evening and campaign hours run about 5x flat average, so size the accept path for ~5.8K/s.
- Channel fan-out
- 1.8x → 180M sends/day
- in-app 100M (always) + push 60M (60%) + email 18M (18%) + text 2M (2%) = 180M channel sends, or 1.8 per notification.
- Send rate
- ~2.1K/s avg · ~10K/s peak
- 180M ÷ 86,400s = 2,083/s, ~10.4K/s at the 5x peak. That is what the log and the dispatcher carry, and it is not what the vendors carry.
- Vendor-bound sends
- 80M / day ≈ 0.9K/s
- In-app never leaves the building, so push 60M + email 18M + text 2M = 80M/day, 926/s average and ~4.6K/s at peak. This is the number that sizes the worker pools and the vendor contracts. Email is ~210/s of it and text ~25/s — the expensive channels are the small ones.
- Row size
- ~1 KB + 0.3 KB per attempt
- Notification row is ids, type, template key, rendered variables, status: about 1 KB. Each delivery attempt adds ~0.3 KB of vendor id, address, and result.
- Storage per day
- ~154 GB / day
- 100M x 1 KB = 100 GB of notifications, plus 180M x 0.3 KB = 54 GB of attempts.
- Hot retention
- 90 days ≈ 14 TB
- 154 GB x 90 = 13.9 TB, partitioned by day so expiry is a partition drop rather than a delete storm. Older partitions go to object storage.
- Inbox reads
- 200M / day ≈ 2.3K/s
- 20M users x 10 app opens. Every one of them wants the unread badge, which is why the badge is a counter and not a count query.
- Outage backlog
- ~375K held / 30 min
- Email at 208/s x 1,800s. At ~1 KB each that is under a gigabyte of queue, so a half-hour vendor outage is cheaper to hold than to fail.
- Broadcast drain
- 5M in ~17 min
- A 5M-recipient broadcast expanded at a 5K/s bulk ceiling takes 1,000s. The ceiling is a decision, not a limit: the send path is provisioned for a ~10K/s peak, so 5K/s spends about half the headroom that sits idle at average load and none of the transactional lane.
Inbox fan-out on write vs on read
Reads beat writes by ten to one and the unread badge sits on the critical path of every app open, where a fan-in query would be doing work for people who never look.
One delivery queue vs a queue per channel
Isolation is the entire point. A stalled vendor should own its own backlog, and drain rate, concurrency, and retry policy are genuinely different per channel.
At-least-once with dedupe vs exactly-once
The last hop is a third party that can accept a message and lose it, so exactly-once ends at their door no matter how careful we are upstream. Cheap detection beats expensive prevention.
A synchronous lane for urgent sends vs async for everything
A parallel path would have to re-implement preferences, dedupe, suppression, and audit, and the copy always drifts from the original. Buying the latency back with a dedicated lane and headroom is cheaper than maintaining two systems.
Digests vs immediacy
The fastest way to lose a channel forever is to make someone turn it off, and the third notification in an hour is what does it.
Our templates vs the vendor's
One renderer, one localization path, one preview, one place a legal change lands — and the freedom to move a channel to a different vendor in an afternoon.
A vendor goes down
Success rate for one channel falls off a cliff and that channel's consumer lag climbs while the others stay flat.
The breaker opens on sustained errors, the channel queue absorbs the backlog — half an hour of email is under a gigabyte — and recovery drains on a ramp instead of all at once. Urgent types fail over to a secondary vendor; everything else waits, because waiting is free and a second vendor is not.
A poison message
One partition stops advancing and the same notification id appears in the error log every few seconds, forever.
Attempt count travels on the message, with a hard cap. Past the cap it goes to the dead-letter queue with the payload and the exception, the consumer commits past it, and dead-letter arrival rate is an alert rather than a folder nobody opens.
Accepted and never dispatched
The caller has an id and a 202, support can see the row, and nothing was ever sent — no delivery attempt, no suppression reason, no error anywhere.
This is the dual write between the row and the log, and the outbox is what closes it: both commit in one transaction and a relay publishes from the outbox, so a crash after the commit is a delayed publish rather than a lost one. Behind that, a sweeper over accepted rows older than a few minutes with no dispatch record — it should find nothing, and the day it finds something is the day the relay is broken.
A retry storm
The vendor recovers, every worker retries in the same second, and it falls over again — and this second outage is ours.
Exponential backoff with full jitter, a concurrency ceiling per vendor, and a half-open breaker that lets exactly one request test the water before the rest are allowed through.
A duplicate delivery
Someone gets the same alert twice, usually minutes after a deploy or a consumer rebalance.
The dedupe claim carries a TTL longer than the retry budget, so the second copy loses the race. Per-attempt rows are what let support tell a duplicate send from a duplicate render on the device — without them the conversation is two people guessing.
A preference change races an in-flight send
Someone turns a channel off and gets one more message a minute later — the one they screenshot.
Preferences resolve at the dispatcher, at send time, rather than back at accept — which shrinks the window from however long the log is deep to however long the queue is deep. Past that point the message is claimed and on its way, and anything already handed to a vendor is gone. So the honest answer is a window measured in seconds, stated out loud and monitored, not a re-check I would have to draw on every sender and would still lose the race at the last hop.
A broadcast or a celebrity fan-out
One request expands into millions of sends, shared queues back up, and unrelated transactional traffic goes late for everyone.
Broadcasts enter on the bulk lane of each channel queue, expand in bounded batches under that lane's drain ceiling, and never share partitions with transactional work — 5K/s of bulk against a path provisioned for ~10K/s leaves the urgent lane untouched. The expansion checkpoints as it goes, because a five-million-row fan-out will be interrupted at some point and restarting it from zero would send the first two million twice.
Quiet hours across time zones
A batch built in one region wakes people in another at 3am, and complaints spike in a country nobody tested.
Quiet hours evaluate in the recipient's stored time zone at send time, never the sender's. Recipients with no time zone fall back to the tenant default and the conservative window, and a held message rolls into the next allowed window instead of being dropped.
The counter cache is lost
Every badge in the product reads zero after a cache restart, and support hears about it before the graph does.
The counter is derived, never authoritative: a miss rebuilds it from the inbox rows on read, at a cost paid once per user. Treating it as losable is what lets it be fast in the first place.
What is actually being scored while you talk. None of it is the drawing.
- 01Whether you asked what delivered means before you drew a single box.
- 02Whether accepting and sending are separated, and whether you can say what each one is allowed to be slow at.
- 03Whether preferences, unsubscribe, and suppression are load-bearing from the start or bolted on when the interviewer asks.
- 04Whether you say at-least-once out loud and then say what makes it safe, instead of promising exactly-once and hoping nobody follows up.
- 05Whether the arithmetic is round numbers you can defend and re-derive, rather than precision you invented on the spot.
- 06Whether every box came with a failure mode you volunteered before being asked for one.
- 07Whether you noticed the inbox is a read-heavy problem and treated it as a different system.
- 08Whether you name the cost of each choice — an answer with no cost is an answer nobody believes.