Prep · System Design

Notification System

Accept in milliseconds, deliver through vendors that lie, and never send the same thing twice.

The Prompt

Design a notification system.

Ask First

Nothing gets drawn until these are answered. Each one moves a box on the board or removes it.

  1. 01Which channels are in scope — in-app, push, email, text — and which one are we contractually worst at losing?
  2. 02Transactional, marketing, or both under one system? They disagree about consent, about ordering, and about who is allowed to be late.
  3. 03Who triggers a send: our own services, or tenants calling an API? That decides authorization, quotas, and how hostile the input is.
  4. 04Is latency or ordering contractual for any type? A one-time code is a promise. A weekly digest is not.
  5. 05Do we own templating and localization, or does the vendor render for us?
  6. 06What does delivered mean for reporting — handed to a vendor, accepted by a device, or seen by a person?
  7. 07How long do we have to be able to answer what we sent someone and why?
  8. 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
  1. 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.

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

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

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

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

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

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

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

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

Requirements

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.
The Arithmetic

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.
Trade-offs
01

Inbox fan-out on write vs on read

ChoseOne inbox row per recipient, written at fan-out
OverAssembling each user's feed from events at read time

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.

CostA broadcast writes millions of rows before anyone reads one, storage grows with recipients rather than events, and correcting a bad template means rewriting what you already fanned out.
02

One delivery queue vs a queue per channel

ChoseA queue, a worker pool, and a breaker per channel
OverA single delivery queue with a channel field on the message

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.

CostMore topics, more consumer groups, more dashboards to keep honest, and cross-channel ordering for one user is no longer free — if it is ever required, it has to be built.
03

At-least-once with dedupe vs exactly-once

ChoseAt-least-once everywhere, with a claim on the send key
OverAn exactly-once protocol spanning our log, our queues, and somebody else's HTTP API

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.

CostA duplicate is still reachable if the claim expires before a very late retry, or if the claim store is flushed. We bound that window and state it; we do not close it.
04

A synchronous lane for urgent sends vs async for everything

ChoseOne async path, with a priority lane and a bypass flag for urgent types
OverA second synchronous path that calls the vendor inline for codes and alerts

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.

CostUrgent sends still pay a queue hop, so the p99 on a one-time code depends on keeping that lane empty — which means capacity we deliberately do not use.
05

Digests vs immediacy

ChoseHold non-urgent types above a per-user rate and roll them up
OverSending everything the moment the event happens

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.

CostLatency on the held types, a scheduled sweep that is now a component with its own failure mode, and a rollup that has to degrade to individual sends when the buffer is unavailable.
06

Our templates vs the vendor's

ChoseOwn templating and localization, render before the vendor call
OverVendor-side templates with variables passed through

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.

CostWe give up vendor features we would otherwise get for free, and we carry a rendering path that has to be fast enough to sit inside the send loop.
Failure Modes
When

A vendor goes down

Symptom

Success rate for one channel falls off a cliff and that channel's consumer lag climbs while the others stay flat.

Mitigation

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.

When

A poison message

Symptom

One partition stops advancing and the same notification id appears in the error log every few seconds, forever.

Mitigation

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.

When

Accepted and never dispatched

Symptom

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.

Mitigation

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.

When

A retry storm

Symptom

The vendor recovers, every worker retries in the same second, and it falls over again — and this second outage is ours.

Mitigation

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.

When

A duplicate delivery

Symptom

Someone gets the same alert twice, usually minutes after a deploy or a consumer rebalance.

Mitigation

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.

When

A preference change races an in-flight send

Symptom

Someone turns a channel off and gets one more message a minute later — the one they screenshot.

Mitigation

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.

When

A broadcast or a celebrity fan-out

Symptom

One request expands into millions of sends, shared queues back up, and unrelated transactional traffic goes late for everyone.

Mitigation

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.

When

Quiet hours across time zones

Symptom

A batch built in one region wakes people in another at 3am, and complaints spike in a country nobody tested.

Mitigation

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.

When

The counter cache is lost

Symptom

Every badge in the product reads zero after a cache restart, and support hears about it before the graph does.

Mitigation

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.

Listening For

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.