Drill 12 — The Queue Backs Up
A notification and webhook system, a 2-million-message backlog, and every way the recovery can hurt more than the outage
Time box: 45 minutes. Uses Ch 2 — Retries and Idempotency, Ch 3 — Isolation and Blast Radius, Ch 4 — Overload Protection, Ch 6 — Graceful Degradation, Ch 8 — Observability and Recovery. Sims: queue-backpressure, rate-limiter. Background: Q64 — Notification system, Q72 — Webhook delivery, Q43 — Delivery guarantees, SD 2 Ch 14 — Message queues.
How to Run This Drill
Read the prompt and the baseline design. Start a 45-minute timer. At each break, stop and answer the "Before you read on" questions out loud, in the order detect → contain → degrade → recover → prevent, with a number in each step. Commit to the timeline's prediction before you step past it. Then open the answer.
A queue is the component interviewers love to break, because it hides failure. Nothing returns an error. The queue simply gets longer, and the damage arrives later: messages that are too old to matter, a recovery that floods someone else, duplicates nobody planned for. Every break in this drill is about time, so every answer needs a rate and a duration.
The one formula you need all drill:
time to drain = backlog ÷ (drain rate − arrival rate)
The drain rate is the smallest of three numbers: what your workers can do, what each downstream accepts, and what you decide to allow.
The Prompt
"Design the notification service for a B2B SaaS product. Other services call it when something happens: an order ships, a password reset is requested, an invoice is paid. It delivers on three channels: email through a third-party email provider, mobile push, and webhooks to URLs our customers register.
Scale: about 85 million notifications a day. That's 1,000 a second on average and 1,500 at the daily peak. The mix is 60% email, 30% push, 10% webhooks. Webhooks go to about 5,000 customer endpoints.
Requirements: never lose a notification. One-time passcodes and password resets must arrive within 30 seconds. Everything else within 5 minutes. Duplicates should be rare.
Constraint: our email provider contract allows 1,500 sends a second. Above that it returns
429 Too Many Requestswith aRetry-Afterheader."
Numbers to write on the board first
| Quantity | Value | Working |
|---|---|---|
| Arrival rate | 1,000/s average, 1,500/s peak | 85M ÷ 86,400 s ≈ 1,000/s |
| 600/s average, 900/s peak | 60% of arrivals | |
| Critical (passcodes, resets) | about 30/s | 3% of traffic |
| Bulk (digests, marketing) | about 200/s, nearly all email | 20% of traffic |
| Transactional + critical email | about 400/s average, 700/s peak | email minus bulk |
| One send | about 0.5 s | provider round trip |
| One worker pod | 100 sends/s | 50 sends in flight ÷ 0.5 s |
| Worker pool | 20 pods = 2,000/s | 2× the average, 1.33× the peak |
| Spare capacity at average load | 1,000/s | 2,000 − 1,000 |
| Email headroom at peak | 600/s | 1,500 − 900 |
The spare capacity is the number that decides every recovery in this drill. Write it in a corner of the board.
The Baseline Design
This is what a decent candidate draws in 15 minutes. It works on a good day.
| Component | What it does | What it assumes |
|---|---|---|
| Notify API | Validates, writes a notifications row, publishes a message | The queue is always reachable |
| Queue | One queue for everything. At-least-once: a message stays until a worker acknowledges it. If not acknowledged within 30 s (the visibility timeout), it becomes visible again for another worker | Workers finish in well under 30 s |
| Workers | Receive a message, render the template, call the channel, mark the row sent, acknowledge | Every message can be rendered; every downstream is up and fast |
| Retries | On any error the worker sends a negative acknowledgement (NACK) and the message is redelivered immediately | Errors are brief |
Keep this picture. Each break removes one of the assumptions in the right-hand column.
Break 1 — The Consumers Stop
"At 09:00 a bad config push makes every worker pod crash on startup. It takes 33 minutes to notice and revert. Producers never stopped. There are now 2 million messages in the queue. Talk me through it."
Before you read on:
- What did users notice first, and which notifications in that backlog are already worthless?
- Workers are back at 09:33. When is the queue empty? Do the arithmetic.
- What alert should have fired at 09:01, and why is "queue depth" the wrong one?
The queue-backpressure sim runs the same arithmetic at toy scale. On the Producer spike tab, lock in a guess and press Play. Four consumers do 20 msg/s, a 6-second spike to 30/s leaves 64 messages queued, and the Time to drain stat shows them clearing at only the spare 5/s: about 12 seconds, twice as long as the spike. After the answer appears, drag the Consumers slider while it runs.
Answer
Detect. Alert on the age of the oldest message, per lane, and on the acknowledgement rate dropping to zero. Depth alone misleads: 50,000 waiting is normal at a busy minute and fine if they are 20 seconds old. Age measures what users feel. A sound pair of alerts is "oldest critical message older than 60 s" and "ack rate below 10% of the arrival rate for 2 minutes". The second one would have paged at 09:02, not 09:33.
Contain. Revert the config. Then ask why one push reached all 20 pods at once: worker config should roll out to one canary pod first and stop if it fails its health check.
Degrade. Not every message in the backlog deserves to be sent.
| Type | Share | In the 2M backlog | What to do with it |
|---|---|---|---|
| Passcodes, password resets | 3% | 60,000 | Send first. Drop any older than its validity (10 min): the code is dead, and the user has already asked for another |
| Receipts, shipping, webhooks | 77% | 1.54M | Send in arrival order. Late is still useful |
| Digests, marketing | 20% | 400,000 | Delay, or drop and regenerate tomorrow's digest |
Recover. The baseline has one queue, so the passcodes wait their turn behind 2 million other messages. With a separate critical lane, the 60,000 critical messages drain at the full 2,000/s in 30 seconds. Then decide whether to scale out for the rest:
- 20 pods: 2,000/s capacity, 1,000/s spare → 2M ÷ 1,000 = 33 min.
- 40 pods: 4,000/s capacity, but email is capped at 1,500/s by the provider. Email backlog 1.2M, email arrivals 600/s, so email spare is 900/s → 1.2M ÷ 900 ≈ 22 min. Push and webhooks (800,000) get the other 2,500/s, minus 400/s new arrivals → 800,000 ÷ 2,100 ≈ 6 min.
Doubling the pods cut the drain from 33 minutes to 22, not to 16, because the provider limit became the bottleneck. The drain rate is the smallest limit in the path. Say that sentence in the room.
Prevent.
- Split one queue into lanes by priority:
critical,transactional,bulk. Workers always take fromcriticalfirst. - Give each message an expiry (
expires_at). Workers drop expired messages and count them on a metric instead of sending them. - Roll out worker config and code with a canary, like any other deploy.
- Alert on age and on ack rate, as above.
The trade-off: priority lanes add a scheduler that someone must own, and expiry means the system deliberately drops messages it accepted. Write down which types may expire, and get the product owner to agree, before the outage, not during it.
The follow-up: "Product wants recovery in 5 minutes next time. How many workers do you add?"
Answer
Do the sum out loud. To clear 2M in 300 s while 1,000/s keeps arriving, you need 2,000,000 ÷ 300 + 1,000 ≈ 7,700 msg/s: 77 pods. Of that, 60% is email, about 4,600 sends a second, three times the provider's limit. The extra pods would mostly collect 429s.
So the answer is not "more workers". It is: drain what matters fast and decide what not to send. Critical messages recover in 30 seconds with lanes. Transactional mail is limited by the provider, about 22 minutes after a 33-minute outage. Bulk can wait hours. If the business needs faster email recovery, that is a bigger provider contract or a second provider, and it has a monthly cost you can name. Compare it with how often you expect a 30-minute outage.
Break 2 — The Poison Message
"11:20. Someone ships a new version of the 'order shipped' email template. It reads
order.carrier.name. Digital orders have no carrier. Within four minutes, throughput is zero and every pod is in a crash loop."
Before you read on:
- Why does one bad message take down every pod, not just one?
- What stops a message from being retried forever, and where must that counter live?
- What do the customers with digital orders get while you fix the template?
In the sim's Poison message tab, the bad message doesn't crash its consumer. It pins the consumer in an endless immediate retry, which does the same damage more slowly. Lock in a guess and press Play: one consumer after another turns red, and by about 13 s all four are stuck and throughput is zero. Then choose 3 tries → DLQ under On failure and press Play again. The "Your runs" table shows the poison parked in the dead-letter queue and the good messages flowing.
Answer
Detect. Pod restarts climbing, the receive count per message climbing (most managed queues expose how many times a message has been delivered), and ack rate falling while arrivals stay flat. A log line TypeError: Cannot read properties of undefined (reading 'name') repeated with the same template id points at the cause within a minute.
Contain. Roll the template back to v6. Templates are code: they need the same versioning, canary and rollback as code. Until the rollback lands, a switch that makes the worker skip order_shipped (send it to the dead-letter queue) stops the crashes in seconds.
Degrade. Digital-order customers get a fallback template: a plain "Your order #1234 has shipped" with no carrier details. A worse email on purpose is better than none.
Recover. The 230,000 waiting messages drain at the spare 1,000/s: about 4 minutes. Messages parked in the dead-letter queue (DLQ) are replayed after the template is fixed, using a replay tool that re-publishes them at a controlled rate.
Prevent. Three layers, because each catches what the one before misses:
- Never let one message crash the process. Wrap the handling of each message in
try/catch, including the asynchronous parts:awaitthe renderer so its errors reach the catch instead of escaping as an uncaught exception. A message that fails must fail alone. - Classify the error. A render error, a
400, or a404on a webhook URL is permanent: retrying will never work, so send the message to the DLQ on the first failure. A timeout, a5xxor a429is transient: retry with exponential backoff and jitter, through a delay queue, not an immediate NACK. (Ch 2 has the table of what is retryable.) - Cap the attempts in the queue, not in the worker. A counter in the worker's memory dies with the worker. The queue's own delivery count survives crashes. After 5 deliveries, the queue moves the message to the DLQ. SQS calls this a redrive policy with
maxReceiveCount, and RabbitMQ quorum queues have a delivery limit.
Then alert on DLQ depth > 0 for critical messages, and on DLQ growth rate for the rest.
The trade-off: a DLQ is a place where messages go to be forgotten unless someone owns it. Give it an owner, an alert, and a replay tool, or you have only swapped a loud failure for a quiet one.
The follow-up: "You set the max delivery count to 5 and leave the crash in. Is that enough?"
Answer
No. Forty new poison messages arrive every second. With a cap of 5, each can still kill up to 5 pods before it is parked, so the fleet faces about 200 crashes a second. That is far more than 20 pods can absorb when each restart takes 10 seconds or more. The delivery count protects you from one bad message. A bad class of messages needs the per-message try/catch and the "permanent error → DLQ at once" rule, so a bad message costs one failed attempt and no crash. Delivery caps are the last line of defence, not the first.
Break 3 — The Email Provider Says Slow Down
"10:00. Marketing launches a campaign: 3 million emails, all published to the queue in five minutes. The email provider starts returning
429. A user who requests a password reset at 10:05 gets it at 10:40."
Before you read on:
- Why is the password reset waiting behind marketing? Name the design decision that caused it.
- What does an immediate retry of a
429do to the provider's count, and to your workers? - How fast should the campaign go out? Give a number.
Answer
Detect. The 429 rate from the provider, and the oldest-message age on the passcode and reset types breaching 30 s. Both page.
Contain. Pause the campaign: stop consuming bulk messages. If bulk shares the queue, you can't pause it without pausing everything. That is the design flaw. In the baseline the only lever is "stop all email", which hurts more.
Degrade. Marketing goes out late. Nobody is harmed by a newsletter arriving at 11:00 instead of 10:00.
Recover. Once lanes exist, the campaign drains at whatever the provider has left over:
- Run a client-side token bucket per provider at 1,350/s, 90% of the contract. The 10% margin covers clock differences and the provider counting slightly differently from you.
- Critical and transactional email have first claim: about 400/s on average.
- Bulk gets the rest: 1,350 − 400 = 950/s. Three million emails take 3,000,000 ÷ 950 ≈ 3,200 s ≈ 53 minutes. Tell marketing their campaign takes about an hour. They will accept it if they hear it before the launch.
Handle 429 correctly. The limit is per account, not per message, so a 429 means the whole sender must slow down. Honour Retry-After by pausing the provider's token bucket for that long. Don't NACK each rejected message to retry it alone, which only produces more 429s.
Prevent.
- Priority lanes (from Break 1) with a scheduler per provider: it takes tokens from the bucket and fills them from
critical, thentransactional, thenbulk. - Rate-limit the producer side for bulk: a campaign is published to its own lane, or published gradually. It is never 3M messages into a shared FIFO.
- Many teams send marketing through a separate provider account or stream with its own limit and its own sender reputation. That is a bulkhead on the provider side.
The trade-off: strict priority can starve bulk forever on a busy day. See the follow-up.
The follow-up: "Transactional email peaks at 700/s at 10:00 every day, and on a bad day it surges to 1,300/s. With strict priority, the campaign gets 650/s on a normal day and almost nothing on a bad one. Marketing complains. Now what?"
Answer
Use weighted shares with a floor, not strict priority. Critical always goes first: it's 30/s, too small to matter. Then transactional gets up to 85% of the bucket and bulk is guaranteed at least 15%: 0.15 × 1,350 ≈ 200/s. At the normal 10:00 peak, transactional uses 700 and bulk takes the remaining 650. In a surge to 1,300/s, transactional is held to about 1,150 and bulk keeps its 200/s: the extra transactional mail waits a few minutes, which is within its 5-minute target. Then move the campaign's start to a quieter hour, or buy a higher limit for the marketing stream. Strict priority is right for passcodes. For two kinds of useful mail, a guaranteed floor is fairer and easier to explain.
Break 4 — The Same Email Twice
"Support has 300 tickets: customers got two receipts for one order, and one customer's warehouse shipped an order twice because our
order.paidwebhook arrived twice. The queue is at-least-once. Explain the duplicates and stop them."
Before you read on:
- List every way a message gets processed twice in this design. There are at least three.
- Can you make delivery exactly-once? If not, what can you promise?
- What do you need from the email provider and from webhook receivers?
Answer
The three sources of duplicates:
| Source | When | Size today |
|---|---|---|
| Visibility timeout expires mid-send | Provider slower than the timeout | Every send slower than 30 s |
| Worker dies after sending, before acking | Crash, OOM kill, deploy without draining | Up to 49 per crashed pod (Break 2: about 1,000 per crash wave) |
| Ack lost or failed | Network error on the ack call | Rare, but never zero |
Detect. Count sends per notification_id and alert when the ratio of sends to notifications rises above 1.001. Without that metric you learn about duplicates from support tickets, as here.
Contain. Make the worker's own deadline shorter than the visibility timeout. A send that has not finished in 10 s is abandoned by the worker, with a timeout on the HTTP call, so it never overlaps a redelivery. Raise the visibility timeout to 60 s, and have long jobs extend it with a heartbeat. The rule: worker deadline + processing time < visibility timeout ÷ 2.
Prevent. You cannot make delivery exactly-once. When a call times out, the sender cannot tell "the provider got it and the reply was lost" from "it never arrived" (the "timeout means unknown" problem in Ch 2). What you can build is at-least-once delivery with deduplication, which looks exactly-once to users in all but a tiny crash window.
-
A claim record before sending. One row per notification and channel:
sqlINSERT INTO deliveries (notification_id, channel, state, claimed_at) VALUES ($1, $2, 'sending', now()) ON CONFLICT (notification_id, channel) DO UPDATE SET state = 'sending', claimed_at = now() WHERE deliveries.state = 'sending' AND deliveries.claimed_at < now() - interval '60 seconds' RETURNING state;A row back means you own the send. No row means someone else is sending it or it is already
sent, so acknowledge and skip. After a successful send, setstate = 'sent'. A claim older than 60 s is taken over, because its worker probably died. Tested on Postgres 18: the second claim returns no row, and a stale claim is taken over. -
Pass an idempotency key downstream. If the email provider accepts one, send
notification_id, so that the remaining crash window (sent, then died before writingsent) is deduplicated on the provider's side. Not every provider supports this. Find out before you rely on it. -
Make webhook receivers deduplicate. Every webhook carries a stable event id header, the same on every retry, and the documentation says: "We deliver at least once. Use the event id to ignore duplicates." The warehouse that shipped twice was missing exactly this. It's the same contract as in Q26 and Q72.
The trade-off: one extra Postgres write per send, about 1,000 writes a second at average load, plus an index. That is cheap next to a double shipment.
The follow-up: "Your claim row says sending, the worker died, and you don't know if the email went out. Send it again or not?"
Answer
It depends on the type, and you decide per type, in advance. For a passcode or a password reset, send again: a duplicate is harmless and a missing one locks the user out. For a receipt, send again: a duplicate is mildly annoying. For a webhook, send again with the same event id, because the receiver is required to deduplicate. Only skip when a duplicate does real harm and the downstream has no idempotency key, for example an SMS that costs money and confuses users. In that case, mark it unknown and reconcile against the provider's delivery log before resending. The general rule: when the outcome is unknown, the cost of a duplicate and the cost of a loss decide it.
Break 5 — The Recovery Floods the Customer
"One of our biggest customers, Acme, had their webhook endpoint down from 14:00 to 16:00. We kept retrying. At 16:00 their endpoint came back. Our retry scheduler has a rule: when a send to an endpoint succeeds, release that endpoint's waiting retries at once, so nobody waits up to an hour for the next timer. Acme fell over again within a minute, and now everyone's webhooks are late."
Before you read on:
- How big is the backlog for Acme, and how fast can Acme take it?
- Why are other customers' webhooks late? Find the shared resource.
- How fast should you send to an endpoint that just recovered, and how do you find that out without asking?
The rate-limiter sim shows the danger in the limiter you'd reach for first. On the Burst tab, a quiet client sends 40 requests in one second against limiters set to 10/s. Lock in a guess and press Play. The token bucket lets about 30 through: the 20 tokens it saved while the client was quiet, plus the refill. A recovering endpoint is exactly that client: quiet for two hours, so its bucket is full.
Answer
Detect. Per-destination error rate and latency, and the send slots in use per destination. An alert of "one destination holds more than 10% of all send slots" finds this in seconds.
Contain. Two mechanisms, both per destination:
- A concurrency cap per endpoint: at most 10 requests in flight to any one webhook URL. Now Acme can hold 10 slots, not 1,000, and the other 990 keep serving everyone else. This is a bulkhead: separate capacity per downstream, so one sick dependency can only use up its own share (Ch 3).
- A circuit breaker per endpoint: after 20 consecutive failures, stop sending to Acme. Send one probe every 30 s and resume only on success. While the breaker is open, 144,000 messages wait in Acme's backlog, not as 144,000 retry timers each firing on its own schedule.
Degrade. Acme's webhooks are late. Everyone else's are on time. Acme can also fetch current state from the API, and webhooks should be designed so that is always possible.
Recover. Drain Acme at a rate it can take, and increase the rate only while it stays healthy. Start at 2× its normal rate, 40/s. Every minute that the error rate stays under 1%, add 10/s. On any 5xx or timeout, halve it. This is additive increase, multiplicative decrease (AIMD), the same idea TCP uses to find a link's capacity without being told. The arithmetic:
| Drain rate | Spare over Acme's 20/s of new traffic | 144,000 ÷ spare |
|---|---|---|
| 50/s (their stated capacity) | 30/s | 4,800 s ≈ 80 min |
| Ramp 40 → 100/s, about 75/s on average | about 55/s | about 45 min |
| 200/s, if Acme scales up for the catch-up | 180/s | 800 s ≈ 13 min |
Tell Acme the number and let them choose. Some customers prefer "skip to latest": drop the backlog and resync from the API.
Size the bucket for recovery. The sim's lesson: a token bucket's capacity is the burst it allows after a quiet period, and the endpoint is weakest exactly after a quiet period. Keep the per-destination bucket's capacity at about one second of its rate, and reset it to empty when a breaker closes.
Prevent.
- Per-destination concurrency caps, rate limits and breakers. Keep each destination's backlog as its own queue (or a partition of the webhook queue keyed by destination), so the scheduler can round-robin across destinations, and one customer's backlog never sits in front of another's.
- Apply the same rule to the email provider after Break 1's drain: the provider's bucket, not the worker count, sets the pace.
The trade-off: per-destination state for 5,000 endpoints (a bucket, a breaker and a backlog each) is more moving parts than one queue. It is the price of keeping one customer's bad afternoon from becoming everyone's.
The follow-up: "Acme's webhooks must arrive in order: order.created before order.paid. Your retries and backlog drain can reorder them. What do you do?"
Answer
Strict ordering and parallel retries don't mix, so first ask how strict it must be. Usually, per-entity order is enough: all events for order #1234 in order, while different orders can interleave. Partition each destination's queue by entity id, and send one event at a time per partition. A failed event blocks only that order's later events. Put a sequence number and a timestamp in every event, so receivers can drop an event older than what they have already applied. The most robust contract is thin events: the webhook says "order #1234 changed", and the receiver fetches the current state from the API. Then order and duplicates stop mattering, at the cost of one API call per event.
The Final Design
| Change | Motivated by | What it costs |
|---|---|---|
| Alert on oldest-message age and ack rate, per lane | Break 1 | Metrics per lane |
| Canary rollout for worker config and templates | Breaks 1, 2 | A slower rollout |
| Priority lanes: critical / transactional / bulk | Breaks 1, 3 | A scheduler to own |
expires_at per message, dropped and counted | Break 1 | Agreeing in advance on which types may expire |
Per-message try/catch, permanent errors → DLQ at once | Break 2 | Error classification code |
| Delivery cap in the queue (5) → DLQ, with owner, alert and replay | Break 2 | A DLQ that someone must watch |
| Fallback templates | Break 2 | A second template per type |
Client-side token bucket per provider at 90% of the limit, Retry-After pauses it | Break 3 | Campaigns take an hour |
| Weighted lane shares with a bulk floor | Break 3 follow-up | Tuning |
| Bulk published gradually, or a separate provider stream | Break 3 | A second sender account |
| Worker deadline 10 s, visibility 60 s plus heartbeat | Break 4 | Slow sends abandoned and retried |
Claim row in deliveries, provider idempotency key, event id on webhooks | Break 4 | About 1,000 extra writes/s |
| Per-endpoint concurrency cap, AIMD rate, circuit breaker, per-destination backlog | Break 5 | Per-destination state for 5,000 endpoints |
| Outbox instead of "insert, then publish" | All: no notification lost if the API dies between the two | A relay process (Q49) |
Scoring Yourself
| Skill | Strong | OK | Weak |
|---|---|---|---|
| Recovery arithmetic | Uses backlog ÷ spare capacity, and spots that the provider limit, not the worker count, bounds the drain (22 min, not 16) | Computes 2M ÷ 2,000 = 16 min, forgetting arrivals continue | "Add more workers and it will catch up" |
| What to send | Separates critical, transactional and bulk, expires dead passcodes, sends criticals in 30 s | Suggests priority without saying what happens to expired messages | Sends all 2M in arrival order |
| Poison messages | Per-message isolation, permanent vs transient errors, a delivery cap kept in the queue, DLQ with an owner | "Add a DLQ" | Retries forever, or drops the message silently |
| Rate limits | Token bucket at 90% of the contract, Retry-After pauses the sender, campaign duration computed (about 53 min) | "Retry 429s with backoff" | Immediate retries, or no idea why the reset was late |
| Duplicates | Names three sources, says exactly-once is impossible across the provider, designs claim row + idempotency key + receiver dedupe | Idempotency key on the API only | "Our queue is exactly-once" |
| Downstream recovery | Per-endpoint bulkhead and breaker, AIMD drain, drain-time table, notices that a full token bucket is a burst | Rate-limits per endpoint | Retries everything as soon as the endpoint is back |
| Shape of the answers | Detect → contain → degrade → recover → prevent, each with a number and a trade-off | Right fixes, out of order | Jumps to a fix without saying what users see |
Five or more strong: you are ready for this round. Mostly OK: redo Breaks 1 and 5 cold tomorrow, out loud, and time the arithmetic.
The Causal Chains
- If arrivals continue during a consumer outage
- If all notification types share one FIFO queue
- If one message can crash the process
- If a
429is retried immediately per message - If the visibility timeout is shorter than the slowest send
- If a recovered endpoint gets its backlog at full speed
What's Next
Drill 13 — One Tenant Takes Everyone Down moves the queue into Postgres and adds LLM providers, credits and a spend cap: the system you will be asked about by name.