When something happens that a user should hear about — a payment went through, someone mentioned them, a package shipped — deliver a notification to that user, over the channel they chose, reliably enough that they get it once. Not zero times. Not twice.
Predict ~1.5 h · Read ~50 min
The problem i
A notification system is the machinery between an event and a buzz. Something happens deep in the product — a payment service commits a charge, a chat service records a mention — and some user, on some device, should find out. The job is to carry that fact out to the right person on the right channel and have it land. On one machine, for one user, this is almost nothing: when the event happens, call the provider that sends the push, and you are done. Everything hard about this system shows up somewhere between "the event happened" and "the buzz landed," and there are two hard facts that the whole design turns on.
Here is the first hard fact. The event that triggers a notification is delivered at-least-once. The service that did the work has to hand off "notify this user" to the part of the system that sends, and that hand-off travels through several stages, and every honest stage can pass the same event along more than once — one stage crashes after acting but before it records that it did, and on restart acts again; another's acknowledgement of "done" gets lost on the way back, so the sender is told to go again. None of that is a bug you can remove. It is the normal behaviour of systems that do not pay for a slow distributed transaction on every single message. So the same "payment succeeded" can reach the point of sending twice, and if nothing stops it, the phone buzzes twice for one payment. Making one event become exactly one buzz, when the event itself arrives more than once, is the central problem of this design.
Here is the second hard fact. The thing that actually delivers to the device is a channel gateway — Apple's APNs and Google's FCM for push, a service like Amazon SES for email, a service like Twilio for SMS — and every one of them is a best-effort, rate-capped black box. Best-effort means none of them promises the notification will arrive; Apple says so in its own documentation, in as many words. Rate-capped means each caps how fast you may send, and past that cap it throttles you or turns you away. Black box means once you have handed a notification over, you cannot see or control what happens next — and the gateway telling you "accepted" is not the gateway telling you "delivered." You cannot make these providers reliable. You can only build reliability in the part of the system you own, which is everything up to the moment you hand off to them.
At the scale we design for, the system sends about thirty thousand push notifications a second at peak. That number is a planning figure, chosen because it is large enough to break the obvious first design, and we will watch exactly where it breaks. It matters right away for one reason: a single push provider tops out around ten thousand sends a second, so thirty thousand a second cannot go through one provider at all — that gap is the first thing that forces the design to grow. Two timings are worth pinning now: a durable write to our own database takes about ten milliseconds, and a check against a fast in-memory store inside our datacenter takes about a millisecond. The call out to a channel gateway is slower and outside our building, which is exactly why we never want it on the critical path of anything the user is waiting on.
Functional requirements what it must do
Turn a business event into a delivered notification on the user's chosen channels.
Deliver each notification effectively once.
Never commit the business fact and then lose the notification, and never notify for a fact that rolled back.
Survive a failed send.
Non-functional requirements what it must be
The pipeline keeps up at peak with no single gateway or node as the bottleneck.
The system stays up, and no single machine's death stops notifications.
Under a flood, the system slows to the gateway's rate instead of collapsing.
Out of scope left out on purpose
The in-app inbox — the little bell with a list of past notifications
How the per-user rate limit and quiet-hours check actually scale
Deciding whether a notification is worth sending at all
Writing the content — templates, rich formatting, translations, and click-tracking
How the design works
The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.
The API
The whole product is these endpoints. Every box in the design below exists to serve one of them.
EVENT (internal trigger — no user-facing endpoint)
Nothing here exposes an endpoint an end user calls. The system is set in motion from inside: when a business service finishes its work, it asks for a notification to be sent. So the contract that matters is the one the business service hands in — who to notify, what event it is for, which channels to use, and a small payload — and the tiny thing that lands on the device: an id and a short line of text, never the full content, because the channel payload is tiny and best-effort, so the app uses the id to fetch the real data when the user opens it. The eventId is the identity of the real-world event, and it is how the system later recognizes the same notification arriving twice.contract ▾
# The system is triggered by an event, not called by a user.## The trigger — a business service submits a notification request (internal)→ submit {
userId: "u_1029",
eventId: "evt_88af13", # identity of the real-world event — how a duplicate is recognized later channels: ["push", "email"],
payload: { ref: "charge_5521", shortText: "Your payment went through" }
}
←202 Accepted # the business service does NOT wait for the buzz to land## On the device — a push carries a hint, never the content← push { id: "charge_5521", text: "Your payment went through" }
# the app uses the id to fetch the real data when the user opens it;# the payload is a hint because the channel is tiny (a few KB) and best-effort.
part 1 The single-server version
Start with the smallest thing that works: one business service that, the moment something notification-worthy happens, calls the channel gateway itself and sends.
First, the words. A notification is a message delivered to a user over a channel — a phone push, an email, an SMS. Each channel has a gateway: the external provider that actually delivers it. For push that is Apple's APNs or Google's FCM; for email a service like Amazon SES; for SMS a service like Twilio. The thing that sets a notification in motion is a business event — a payment cleared, a mention was written, an order shipped. Those three words carry the product: an event happens, and a user gets a notification over a channel.
The day-one design is a single line of code in the business service. The payment clears; right there, in the same function, call the gateway and send the push. It works. For a handful of users and a trickle of events it is genuinely fine, and there is nothing clever missing. It just does not survive success.
The whole system on day one.
Following a request
An event becomes a buzz
a business event happens; the App service calls the channel gateway inline and sends — the best-effort black box sits directly on the business critical path~5 ms
One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.
It fails in three separate ways, each of which forces a piece of the real design. The first is that calling the gateway inline puts a best-effort black box directly in the path of your core business: the payment function now waits on the provider, so if the provider is slow this afternoon, your payments are slow this afternoon — you have made the reliability of your payments depend on a notification provider, which is exactly backwards.
The second is what happens when the send fails, which it regularly will. Inline, you can ignore the failure and lose the notification — the user was charged and never told — or retry inside the payment function and block the payment on a flaky provider. Neither is acceptable, and neither leaves any room to be cleverer, because there is nowhere in this design to hold a notification that has not been delivered yet. The third is the arithmetic: thirty thousand notifications a second is not a busy version of one server, it is a different design that needs many senders — and a single push provider will not even accept that rate. Those three failures point the same way: the send has to come off the business service's critical path, which means something has to sit between the event happening and the notification going out — something that can hold the work, hand it to a pool of senders, and let the business service commit and move on. The next section builds that something, one forced decision at a time.
part 2 The version that survives scale
This section walks the design by use case, in the order you actually have to decide things. First, get the send off the business service's path and understand what the channel gateways really cost. Then make sure the business fact and the notification cannot be split apart. Then face the fact that the event now arrives more than once, and stop it becoming two buzzes. Then survive a send that fails. And finally survive a flood, which turns out to need the opposite of the obvious fix. The finished diagram waits at the end.
Two ways through this section
The five decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.
use case 1 One event, one push
The single-server version wired the business logic straight to the provider, so a slow provider stalled the business itself. This use case cuts that wire, and then confronts the harder half: the three gateways on the other side of it, and what their limits cost.
deep dive 1 The channel and its ceiling
The question
The single-server version welded the business service straight to the gateway, and that weld put a best-effort black box on the business critical path. Break it. Put a queue between the event and the send: when a business event happens, the App service drops a small notification event onto the queue and returns, and a separate pool of channel workers reads the queue and does the sending. The business service is decoupled — it commits its fact, hands off, and never waits on a provider. That much is forced. What is not obvious, and shapes every later decision, is what the workers are now calling: three channels, each through an external provider, and every one of them a best-effort, rate-capped black box.
Take the three channels one at a time, because each teaches the same lesson from a different angle. Phone push, through APNs or FCM, is fast and cheap and the one people most misunderstand. The payload is tiny — a regular push caps at about four kilobytes — so you cannot send the actual content; you send an id and a short line of text, and the app fetches the real thing when the user taps it. And push is explicitly best-effort: Apple's documentation states outright that the delivery of a remote notification is not guaranteed, and tells you never to put anything in the payload that cannot be re-fetched. FCM behaves the same way — it throttles you, caps a project at about six hundred thousand messages a minute, roughly ten thousand a second, and even caps each individual device at a couple of hundred messages a minute, which most designs forget until a runaway loop notifies one user hundreds of times.
Email, through SES, has a different shape but the same lesson. There are two independent limits — a rolling daily quota and a per-second rate — and, the part that surprises people, the quota is charged per recipient, not per send. One email to a fifty-person channel counts as fifty against your quota, not one. That single fact reshapes how you think about volume, and it comes back in a moment. SMS, through Twilio, is the slowest and most rate-limited: send faster than your per-second limit and Twilio does not reject the extras, it queues them and holds a message for up to ten hours before giving up. A short code gets you up to about a hundred a second. SMS is where 'I sent it' and 'it arrived, eventually, or not' are furthest apart.
Three providers, three shapes, the same three properties underneath every one. Each gateway is best-effort: accepted is not delivered. Each is rate-capped, with a hard ceiling you cannot push past. And each takes only a thin payload, so the notification carries a reference, not the content. Those three properties are the premise the entire rest of the design is built to work around.
A queue, one worker pool per channel, thin payload, every gateway best-effort. Decouple with a queue, and run one worker pool per channel, each sending a thin payload and treating every gateway as best-effort. There is no cleverness to choose here — the decision is to respect what the gateways are rather than pretend they are reliable. One pool per channel, because the channels have wildly different rates and failure modes and should not share a fate: SMS being slow should not back up push. The payload is always an id and a short line of text, never the content, because the channel caps it at a few kilobytes and the push may never arrive. And nothing downstream is ever allowed to assume that a gateway accepting a send means a user received it.
Now size the send tier, because this is the first of two sizings and it is the one that forces a box to split. Thirty thousand pushes a second, against a push provider that accepts about ten thousand a second per project, does not fit through one provider. Thirty thousand divided by ten thousand is three, so the push send tier spreads across about three sender pools, each pushing through its own provider project and staying under its ceiling. The email and SMS lanes size the same way against their own, much lower, ceilings. The throughput of the gateways, not the size of any data, is what splits this tier. The workers themselves hold no durable state — everything that must survive a crash lives in the queue, not in a worker — so a worker is a stateless box you scale by adding more of them.
Under the hood — how it works
Fan-out is a multiplier, and it is what really sizes this tier. Because the email quota is charged per recipient, a broadcast notification — one event, many recipients — hits provider ceilings far faster than a one-to-one notification at the same event rate. One 'your team mentioned you' to a fifty-person channel is fifty sends, not one. So the number that sizes the channel tier is not the raw event rate; it is the event rate times the fan-out ratio. A system doing a modest number of events a second, each fanning out to large audiences, can be sending far more than a system doing many more one-to-one events. Size the send tier against fan-out, not against events.
Two more things belong on this tier, and both are deliberately coarse. First, priority lanes: a password-reset code and a weekly marketing digest are both notifications, but one must never wait behind the other, so the queue splits into a transactional lane and a marketing lane at least. Deciding whether a notification is worth sending at all — scoring its relevance — is a ranking problem a machine-learning model solves, and it is a different system we are not building here; the board carries lanes, not a model. Second, the per-user cap and quiet-hours check sit right before a worker hands off to a gateway: the worker looks up the user's recent count and their quiet-hours window and drops or defers a send that would break either. Where that check sits is this design's decision; how the per-user counter stays correct across a fleet is the distributed-counter problem the rate-limiter board solves, so we place the check and point at that board rather than rebuild it.
⚡ From production
This is not an interview simplification. Apple's own developer documentation states that the delivery of a remote notification is not guaranteed, and instructs developers never to include data in the payload that cannot be retrieved by other means, because the push may not arrive. The gateway being best-effort is the vendor's own stated contract, not a pessimistic assumption — which is exactly why the thin payload carries a reference and the id-and-fetch dance exists.
The board after this deep dive — “An event becomes a buzz” traced, hop by hop. The numbered steps below walk the same path.
An event becomes a buzz
the App service publishes a notification event to the queue and returns — the send is now off the business critical path~1 ms
a channel worker, one pool per channel, consumes the event at its own pace~1 ms
the worker calls the channel gateway (APNs / FCM / SES / Twilio) — best-effort, rate-capped, thin payload; accepted is not delivered~5 ms
use case 2 Commit the fact and notify it
Go back to the very first hop, the one we waved through a moment ago. It quietly does two things at once, in two different systems that share no transaction — and the brief said those two things must both happen or neither does. That is a harder promise than it looks.
deep dive 2 The outbox
The question
The last use case said the App service 'drops a notification event onto the queue.' But in the same breath it is committing the business fact — writing 'this payment succeeded' to its own database. Those are two separate systems, the database and the queue, and the brief required that the fact and the notification succeed or fail together. So the real question is whether a write to the database and a publish to the queue can be one atomic operation. They cannot, and the ways people try to fake it are the debate.
The first attempt is the obvious one: write to the database, then publish to the queue right after. Commit the payment, then send the event. The gap between those two steps is the problem. If the process crashes after the commit and before the publish — and processes crash — the payment is recorded and the notification never happens: the user was charged and never told, the exact failure the brief forbade. Reordering does not help — publish first, then commit, and a crash in the gap sends a notification for a payment that never went through. There is no order of two independent writes that is safe against a crash between them.
The second attempt is to reach for a distributed transaction — a two-phase commit spanning the database and the broker, with a coordinator that makes both systems prepare, then commit together. This genuinely gives atomicity. It is also heavy and rarely worth it: it needs a coordinator, it holds locks across two systems while it waits, and if the coordinator stalls, both systems are stuck mid-commit. Most brokers and most teams avoid it, and it is the thing people wrongly assume the real answer is a lightweight version of. It is not.
One myth to kill on the way past, because it is the most common wrong description of the pattern: the outbox is not a lightweight two-phase commit. It is the opposite move. There is no coordinator, no prepare phase, no cross-system agreement. Atomicity is achieved by writing to only one system transactionally — the outbox row lives in the same database as the business row — and then treating the publish as a separate, retryable step that happens afterward. Two-phase commit makes two systems agree in one moment; the outbox refuses to make them agree at all, and instead makes the publish a thing that can safely be retried until it sticks.
The transactional outbox — one local transaction, then a relay publishes. Give the App service's own database a second table — the outbox. When the App service commits the business fact, in the same local transaction it also writes a row to the outbox: an event to publish. One transaction, one database, so it is atomic for free — either both the business row and the outbox row commit, or neither does. Then a separate process, the relay, reads new rows out of the outbox and publishes them to the queue. The business write is now safe and atomic, and the risky part — talking to the queue — has been moved out of the business transaction entirely.
And here is the hinge of the whole design, so slow down on it. The relay reads an outbox row, publishes it to the queue, and marks the row published. What if it crashes after publishing but before it marks the row? On restart it sees an unpublished row and publishes it again. The relay is therefore at-least-once by construction — it will, sometimes, publish the same event more than once. This is not a defect to fix. It is the direct, unavoidable price of not using a distributed transaction: the outbox trades 'the notification might be lost' for 'the notification might be duplicated,' and duplication is the far better problem, because it can be caught downstream while loss cannot be recovered. The whole next use case is about catching it.
Under the hood — how it works
The relay has two well-known forms, and the source names both. The simple one is a polling publisher: the relay periodically queries the outbox for unpublished rows, publishes them, and marks them done. It is easy to build and easy to reason about, and it is the right default. One level deeper is transaction-log tailing, also called change-data-capture: instead of polling the table, the relay tails the database's own transaction log and picks up outbox rows as they are written. It is lower latency and puts no query load on the table, at the cost of being more involved to operate. Start with the polling publisher; reach for log tailing when polling latency or load becomes the thing that hurts.
The failure modes are the good kind. The App service commits the business row and the outbox row in one local transaction, about a ten-millisecond durable write, and calls no gateway inline. If it crashes right after that commit, the outbox row is already safely on disk and the relay picks it up on recovery — nothing is lost. The Outbox · Postgres table holds one row per not-yet-published event and is drained continuously by the relay, so its size is bounded by how far behind the relay is, not by total volume — in steady state it is nearly empty. And if the relay stops, rows accumulate visibly in the table: a backlog you can see and alarm on, not events that silently vanish. The pattern deliberately converts 'events disappear' into 'events pile up where you can watch them.'
⚡ From production
The transactional-outbox pattern exists specifically because a database write and a message-broker publish cannot be one atomic operation across two systems — the dual-write problem — and its documented solution is to store the message in the database as part of the transaction that updates the business entities, then forward it with either the polling-publisher pattern or the transaction-log-tailing pattern. Both relay names are the source's own.
The board after this deep dive — “An event becomes a buzz” traced, hop by hop. The numbered steps below walk the same path.
An event becomes a buzz
the App service writes the event to the outbox in the same local transaction as the business fact — atomic for free~10 ms
the relay reads the unpublished rows out of the outbox~5 ms
the relay publishes to the queue — at-least-once by construction, so the same event can land twice~5 ms
a channel worker, one pool per channel, consumes the event at its own pace~1 ms
the worker calls the channel gateway (APNs / FCM / SES / Twilio) — best-effort, rate-capped, thin payload; accepted is not delivered~5 ms
use case 3 The same notification, sent twice
Atomicity was not free. To get it, the outbox accepted a relay that can fire the same event more than once — and this use case is where that bill lands. The job is to make one event yield exactly one notification, without leaning on the broker to promise it.
deep dive 3 Effectively-once
The question
The relay is at-least-once — that was the honest cost of the outbox, and now it comes due. Because the relay can publish the same event twice, the queue can hand the same event to the workers twice; and even if the relay were perfect, the queue itself redelivers on a consumer rebalance. So watch it with no defense in place. An event lands: notify user U with a push. A worker picks it up, calls the gateway, and U's phone buzzes. Then something ordinary happens — the pool rebalances, or the worker's acknowledgement is lost, or the relay republished after a crash — and the same event is delivered again, to another worker, which also calls the gateway. One event, two buzzes.
The reflex fix is 'the queue should handle this — turn on its deduplication,' and it is worth naming because it is the single most common wrong answer, wrong in a precise, checkable way. A plain queue like Kafka is at-least-once and does no deduplication at all. Amazon SQS in its standard mode says the same in its own docs: you may receive a message more than once, so design your consumer to be idempotent. SQS does offer a FIFO mode that advertises exactly-once — but read the fine print: that guarantee holds only inside a five-minute deduplication window. A duplicate that arrives six minutes after the original is not caught. Notification retries can easily span longer than five minutes — a real outage takes longer than that to clear — so the queue's own dedup window is exactly the wrong length for this problem. No queue in this survey closes the duplicate for you across the timescales that matter. The fix cannot live in the broker.
So the deduplication has to be something the worker does, on its own, out of a key it can recompute. That is the whole move: the identity of the notification — event, channel, recipient — is knowable to the worker without asking the broker anything, so the worker can recognize a redelivered event as the same notification and refuse to send it twice. The broker never had the information to dedup correctly across the timescales that matter; the worker does.
An idempotency key, checked at the channel worker before every send. Give every notification a key that identifies it uniquely — a hash of the event id, the channel, and the recipient. Before a worker sends, it checks a shared store for that key. If the key is absent, the worker writes it and sends. If the key is already present, this exact notification has already gone out, so the worker drops it and does nothing. The first delivery of a redelivered event writes the key and buzzes; the second delivery finds the key and stays silent. The double arrow collapses to one. This is Stripe's idempotency pattern, applied one layer out from where Stripe applies it: the worker is the client of the gateway, the key guards the send, and a redelivered event is a 'retry' that finds its key and is dropped.
Name the property carefully, because the lock is strict about the words. The queue gives at-least-once delivery. The worker's idempotency key adds deduplication on top. At-least-once plus idempotent handling gives effectively-once: every notification is delivered, and duplicates are collapsed, so the user experiences it exactly once even though the machinery underneath delivered it one-or-more times. Do not call it exactly-once — no channel and no queue here actually offers that as a guarantee. Exactly-once is the experience the user gets; effectively-once is the honest name for how you got there, by building deduplication at the edge instead of assuming it in the middle. Now the sizing, which is the second of the two and lands on a different box than the first. Each key is small — a hash, a marker, an expiry, call it fifty bytes. Hold them for a one-hour window at thirty thousand a second and you store about a hundred and eight million keys, roughly five gigabytes, which fits comfortably in one node. So unlike the send tier, whose throughput forced three pools, the dedup store's capacity does not force a split at all — the two sizings pinned different boxes.
Under the hood — how it works
The store's failure mode is a real choice you must name in advance. If a worker cannot reach the dedup store, it has two options and no third. Fail open means send anyway and risk a duplicate. Fail closed means refuse to send and risk the user never getting a notification they should have. For most notifications, fail open is right — a rare duplicate buzz is a smaller harm than a missing two-factor code — but that is a judgment about what the notification is worth, and it must be decided before the outage, not during it. And if a dedup-store node is lost and its keys are gone, every in-flight event on it looks new for a moment, so there is a small burst of duplicates until the keys refill. Both of those are the fail-open direction, which for notifications is the tolerable way to break.
There is a parameter here more interesting than the node count, and it is the real teaching of this box: the dedup window must be at least as long as the retry budget. A key exists only for the length of the window. If a send fails and the worker retries it later — and the next use case builds exactly that retry, with backoff that can stretch out to survive a real outage — then a retry that fires after the window has elapsed finds no key, because the key expired. To that retry the notification looks brand new, so it sends, and the user gets a duplicate. The window and the retry schedule are not two independent knobs. Size the window shorter than the longest a retry can take and the deduplication silently springs a leak. This is the seam between this use case and the next.
⚡ From production
Stripe's API lets a client send an idempotency key with a request so a retried charge is not charged twice: the server stores the key with the first response and replays it for any repeat, and Stripe retains the key for at least twenty-four hours. This dive applies the identical mechanism one layer out — the channel worker is the client of the gateway, the key guards the send, and a redelivered event is a retry that finds its key and is dropped. The mechanism is Stripe's; only the place changes.
The board after this deep dive — “An event becomes a buzz” traced, hop by hop. The numbered steps below walk the same path.
An event becomes a buzz
the App service writes the event to the outbox in the same local transaction as the business fact — atomic for free~10 ms
the relay reads the unpublished rows out of the outbox~5 ms
the relay publishes to the queue — at-least-once by construction, so the same event can land twice~5 ms
a channel worker consumes the event~1 ms
before sending, the worker looks up the idempotency key and gets back present-or-absent — a lookup out, an answer back, a hop pair~1 ms
absent: write the key and call the gateway. Present: this exact notification already went out, so drop it and send nothing~5 ms
use case 4 When a send fails
Every piece is in place and behaving, so it is time to go hunting for what the happy path never had to face. Here it is a send that fails at the gateway and just sits there: the notification never went out, yet nothing threw it away either. What should the worker do with it, now and when it will clearly never go through?
deep dive 4 Retry, full jitter, and the DLQ
The question
The board is built and it is correct: the fact and the notification are atomic, the event flows through the queue, and duplicates collapse at the worker. Before trusting it, try to break it — and the crack is the one thing the happy path glossed. A worker calls a gateway and the send fails: a 429 because the project is over its minute quota, a timeout, a transient provider error. The notification is not delivered and it is not lost; it is sitting in a worker's hands, failed. What does the worker do with it?
The first idea is retry immediately, and keep retrying. It is the worst option for two reasons. A gateway returns 429 precisely because it is over capacity, and hammering it with immediate retries piles more load onto the thing that is already saturated. And a notification to a device that is genuinely gone — an uninstalled app, a dead number — will fail forever, so 'keep retrying' is an infinite loop with no exit.
The second idea is exponential backoff: wait one second, then two, then four, doubling each time, so a struggling gateway gets increasing room to recover. This is most of the way there, and it is what most people say. But AWS names a specific trap in plain exponential backoff, and it is worth respecting: it causes a thundering herd. All the sends that failed at the same instant — say, the moment the gateway briefly went over quota — back off by the same schedule, so they all retry at the same instants, in synchronized waves. The gateway gets slammed at one second, recovers, gets slammed again at two seconds. The backoff spread them in time but kept them in lockstep.
The third idea is the fix AWS recommends, and it is the pick: full-jitter backoff, which keeps the growing window but randomizes within it so the synchronized herd becomes a smooth trickle. With an exit — a bounded attempt count feeding a dead-letter queue — it is both kind to a struggling gateway and finite for a send that will never land.
Full-jitter backoff, with a bounded-attempt exit to a DLQ. Retry with full-jitter backoff, and give it an exit. Instead of waiting exactly the backoff interval, wait a random amount of time between zero and that interval — sleep = random(0, min(cap, base·2^attempt)). The window still grows with each attempt, so a gateway that stays down gets exponentially more room, but randomizing within the window scatters the retries so they no longer arrive in synchronized waves. The herd becomes a trickle. And there has to be an exit for the send that will never succeed: after a bounded number of attempts — a count often called maxReceiveCount — the notification stops being retried and is moved to a dead-letter queue, the DLQ. The DLQ is not a retry queue; it is a holding pen for a send that has exhausted its retries, sitting there for a human or an automated process to inspect — a dead device token to prune, a systemic gateway problem to investigate — rather than being lost or looping.
Here is where the seam from the last use case bites, and it is the subtlety this whole use case turns on. The retry policy has a total budget: with full-jitter backoff capped high enough to survive a real outage, a notification might make its attempts spread across many minutes — longer, easily, than a five-minute dedup window. Recall the rule from the dedup store: the window must be at least as long as the retry budget. Now you can see the whole trap. The DLQ's attempt count has no sense of time; the dedup window is a duration with no sense of attempts. If a late retry fires after the idempotency key has expired, the dedup store has forgotten the notification, so the retry sends and the user gets a duplicate — the very thing the signature dive worked to prevent, undone by a backoff cap set without looking at the window. The backoff cap, the DLQ retention, and the dedup window are one decision made in three places, and they have to be sized together.
Under the hood — how it works
A useful detail from SQS's own guidance ties the DLQ to the rest of the numbers: set the DLQ's retention longer than the source queue's, because a message's age clock keeps running from when it was first enqueued, and you do not want it to expire out of the DLQ before anyone looks at it. The DLQ holds only exhausted sends, so it is small and its size tracks how much is currently failing — a near-empty queue in normal operation and a filling one during a gateway incident, which makes it a useful alarm. Its own risk is exactly the parameter disagreement above: a retention or retry setting that quietly conflicts with the dedup window turns a re-drive into a duplicate.
⚡ From production
AWS's own architecture guidance calls plain exponential backoff a thundering-herd risk and recommends full jitter — sleep = random(0, min(cap, base·2^attempt)) — randomizing the whole backoff window rather than just its length, so retries stop arriving in synchronized waves. The formula and the herd are AWS's own.
For a normal high-volume system, the design is now genuinely done. This last use case starts it already on fire — a real flood that once hit Discord — and the twist is cruel: the very instinct that saved the send tier earlier is what deepens this one. The answer runs the opposite way from adding more machines.
deep dive 5 Backpressure over capacity
The question
Step back and look at what is built. It is correct and complete for a large, ordinary system: atomic hand-off, effectively-once delivery, retries with an exit. Now the premise arrives already broken, the way a real incident does. This is Discord's 2016 problem, and it took their push pipeline down. A perfect storm hit at once — a community crossed twenty-five thousand concurrent users just as a wave of new communities burst onto the platform — and the push pipeline flooded. Pushes, in Discord's own words, arrived late or did not arrive at all. Everything downstream looked healthy; notifications simply stopped landing.
The instinct is to add capacity — spin up more workers, add more sender pools, push harder. It is the reflex that worked two use cases ago when the send tier could not keep up, and here it is exactly wrong. The bottleneck was not Discord's workers. It was inside Firebase: each XMPP connection to Firebase accepts no more than a hundred pending requests at a time. Once you are at that ceiling, a thousand eager workers do not help — Firebase still only accepts a hundred in flight per connection, and everything beyond that queues, backs up, and slows.
Worse, pushing harder into a gateway that is already at its ceiling makes it worse: requests pile up, latency climbs, and 'accepted' stops meaning anything at all, because an accepted request is now sitting in a backlog that may never drain. Adding capacity upstream of a fixed downstream ceiling just moves the pile-up around. The only response that actually helps is to feed the gateway at exactly the rate it can accept and hold the rest in a buffer you control.
Backpressure — pull at the gateway's ceiling and buffer the overflow. Feed the gateway at exactly its rate — backpressure, not capacity. Discord's fix was a two-stage pipeline built for demand-based flow control: a collector receives and buffers incoming push requests, and a pusher pulls from it, but pulls exactly a hundred at a time, matching Firebase's pending-request ceiling, and asks for the next batch only as Firebase acknowledges the previous one. The whole pipeline runs at the speed Firebase can actually accept, by construction. When traffic is normal, the buffer is essentially empty, because the pusher keeps up. When a flood hits, the buffer absorbs it and the pusher keeps feeding Firebase at its steady hundred — the system slows to the gateway's rate instead of collapsing. Discord reports that buffer only engages about once a month, in genuinely catastrophic situations. That is the signature of good backpressure: invisible almost always, and the thing that saves you on the worst day.
The reframe is the lesson to carry out. A channel gateway is a best-effort black box with a hard ceiling, and you cannot push faster than it will accept, so the correct response to a flood is to slow down to the gateway's rate, deliberately, and let a buffer hold the overflow — not to scale your own side into a wall. This closes the loop with the retry dive — raw retries into a saturated gateway are the same mistake in miniature, which is why the backoff had to actually back off — and with the very first use case: 'the gateway accepted it' never meant 'the user got it,' and here is the incident that proves it, with numbers. And a buffer is not infinite, so when a relentless flood finally fills it, the right move is to shed the lowest-priority work — drop the marketing lane before the transactional lane — so the notifications that matter still get through at the gateway's rate. That is where the priority lanes from the first use case earn their keep a second time.
Under the hood — how it works
Load-shedding is the tail of backpressure, and it is where the priority lanes stop being about latency and start being about survival. Backpressure holds the overflow, but a buffer is not infinite; a flood that never relents will eventually fill it. That is when you shed the lowest-priority work — drop the marketing lane before the transactional lane — so the notifications that matter still get through at the gateway's rate. The lanes introduced in the first use case were about keeping a two-factor code ahead of a marketing blast in normal operation; here they become the signal that tells you what to drop first when you finally have to drop something.
⚡ From production
Discord's engineering blog documents the 2016 incident and its fix. Burst notifications brought the entire push system to a slow and sometimes a halt; pushes would arrive late or not arrive at all. The root cause was that each Firebase XMPP connection has no more than a hundred pending requests at a time. The fix was a Push Collector to Pusher pipeline, where the Pusher only demands a hundred requests at a time to ensure it does not go over Firebase's pending-request limit, and the overflow buffer engages about once a month in catastrophic situations. It is a rare and valuable thing: a team explaining, with the exact ceiling number, why the answer to a flood was to slow down rather than scale up.
This design chooses availability over strong consistency, on purpose, and it makes that choice twice. The first time is the outbox. The safe, strongly-consistent way to commit the business fact and the notification together is a distributed transaction — a two-phase commit that makes the database and the broker agree in one moment. The design refuses it: it writes to only one system transactionally and lets a relay publish afterward, which keeps the pipeline available and fast at the cost of a relay that is at-least-once. It accepts that the same event may be published more than once rather than pay the coordination cost of making two systems agree on every message.
The second time is the idempotency key. Having accepted at-least-once delivery, the design does not try to buy exactly-once back from the broker — no queue here offers it across the timescales that matter, and reaching for one would mean coordinating a global exactly-once guarantee the design deliberately declined. Instead it collapses duplicates at the edge, at the worker, out of a key the worker can recompute. The result is effectively-once: every notification is delivered and duplicates are dropped, so the user experiences it exactly once even though the machinery underneath delivered it one-or-more times. Consistency is achieved by construction at the boundary, not by agreement in the middle.
Read the whole pipeline that way and every trade is the availability-first one. A push that goes out twice is a rare duplicate buzz — a small, recoverable harm — so when the dedup store is unreachable the worker fails open and sends anyway. A notification that is lost cannot be recovered, so the outbox is built to never lose one, converting "events vanish" into "events pile up visibly." And under a flood the system slows to the gateway's rate rather than refusing work outright. Nowhere does the design stop the world to be exactly right; everywhere it stays up and makes the cheaper wrong answer recoverable.
There is a name for the underlying choice. When a network fault splits a system in two — that split is the P, for partition — each operation has exactly two honest options: answer anyway from what this half knows and risk being wrong, which is the A, for availability, or refuse until the halves agree, which is the C, for consistency. A partition is not something you choose; it is weather. The useful way to apply the CAP theorem is per operation, by asking what a wrong answer would cost. Here a duplicate buzz costs almost nothing while a slow distributed transaction on every message costs everything, so the design takes availability and effectively-once at every boundary and never pays for strong consistency it does not need.
That is the whole machine, and every box on it was placed by one of the deep dives above. One event is committed and written to the outbox in one local transaction; the relay publishes it to the queue at-least-once; the workers consume it, check the idempotency key against the dedup store, drop it if it is a duplicate and send it if it is new, retry failed sends with full-jitter backoff, dead-letter the exhausted ones, and feed the best-effort gateways at their own rate. One event in, effectively one notification out, from a chain of individually unreliable parts. If you carry away one sentence, make it the one the whole design turned on: build reliability at the edges, not in the middle.
The finished design — every box placed by one of the deep dives above.
What's on the diagram
App service
App service. The business service that owns the event — it commits the fact and writes the notification event to its outbox in one local transaction, and never calls a gateway inline, so a slow provider can't slow the business.
Channel gateways
Channel gateways. The external providers that actually deliver — APNs and FCM for push, SES for email, Twilio for SMS. Every one is best-effort, rate-capped, and takes only a thin payload, so accepted is never delivered and the notification carries an id, not the content.
Queue · Kafka
Queue · Kafka. Buffers notification events between the relay and the workers, split into priority lanes so a marketing blast can't delay a two-factor code. It is a log, so its size is retention times rate, and it redelivers on a rebalance — another source of the duplicate.
Channel workers
Channel workers. One pool per channel (push / email / SMS) so a slow channel can't back up a fast one. Each checks the dedup store before sending, retries a failed send with full-jitter backoff, dead-letters the exhausted ones, and feeds the gateway demand-based under a flood.
Outbox · Postgres
Outbox · Postgres. A second table in the App service's own database holding one row per pending event; because it commits in the same transaction as the business fact, the two can never be split, and a relay outage just makes rows pile up visibly instead of losing them.
Relay
Relay. Reads new rows out of the outbox and publishes them to the queue — a polling publisher by default, log-tailing one level deeper. It is at-least-once by construction: a crash after publishing republishes, which is the duplicate the worker is built to catch.
Dedup store · Redis
Dedup store · Redis. Holds one idempotency key per notification, TTL'd to the dedup window. A worker checks it before every send — present means this exact notification already went out, so drop it — which is what turns at-least-once delivery into effectively-once at the edge.
DLQ
DLQ. The dead-letter queue off the workers, holding sends that exhausted their retries for a human or an automated process to inspect — a dead device token to prune, a systemic gateway problem to investigate — rather than losing them or looping on them forever.
What this design never covered — raise it yourself
Multi-channel fallback and escalation
A strong follow-up is: push failed, now what? The escalation answer is to try a second channel — if the push is not acknowledged within a few minutes, fall back to SMS, then to a phone call for the truly critical ones, which is how paging systems work. The honest catch to name is that escalation needs a delivery receipt — you have to know the push was not seen to escalate — and tracking receipts across best-effort channels is its own piece of work this design deliberately did not build.
The in-app inbox
Real products also keep a browsable list of past notifications behind a bell icon. That is a stored per-user timeline — the news-feed board's materialization problem, not this one's. The strong answer names it as the deliberate cut it is and points at that board, rather than inventing a read-model here.
Deciding whether to send at all
The deepest version of priority is a relevance model that scores each candidate notification — is this worth interrupting the user for? — the way LinkedIn's Concourse scores on the order of half a million candidates a second, an order of magnitude above any gateway's send rate, which is itself why real systems split "decide" from "send." The strong answer names this as the one-level-deeper ranking problem that sits upstream of the whole pipeline, and does not pretend the coarse priority lanes are it.
Per-user rate limits and quiet hours, done properly
This design placed the per-user cap and the quiet-hours check right before the send. The follow-up is how that counter stays correct across a fleet of workers hitting it at once, which is the distributed-counter problem the rate-limiter board solves. The strong answer places the check here and reaches for that board for the counter, rather than re-deriving it.
Go deeper — the primitives this design leaned on
Message queue ↗ — the partitioned log, consumer groups, offsets, and the redelivery on rebalance that is the at-least-once duplicate this design collapses