29 min read

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 Requests with a Retry-After header."

Numbers to write on the board first

QuantityValueWorking
Arrival rate1,000/s average, 1,500/s peak85M ÷ 86,400 s ≈ 1,000/s
Email600/s average, 900/s peak60% of arrivals
Critical (passcodes, resets)about 30/s3% of traffic
Bulk (digests, marketing)about 200/s, nearly all email20% of traffic
Transactional + critical emailabout 400/s average, 700/s peakemail minus bulk
One sendabout 0.5 sprovider round trip
One worker pod100 sends/s50 sends in flight ÷ 0.5 s
Worker pool20 pods = 2,000/s2× the average, 1.33× the peak
Spare capacity at average load1,000/s2,000 − 1,000
Email headroom at peak600/s1,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.

ComponentWhat it doesWhat it assumes
Notify APIValidates, writes a notifications row, publishes a messageThe queue is always reachable
QueueOne 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 workerWorkers finish in well under 30 s
WorkersReceive a message, render the template, call the channel, mark the row sent, acknowledgeEvery message can be rendered; every downstream is up and fast
RetriesOn any error the worker sends a negative acknowledgement (NACK) and the message is redelivered immediatelyErrors 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:

  1. What did users notice first, and which notifications in that backlog are already worthless?
  2. Workers are back at 09:33. When is the queue empty? Do the arithmetic.
  3. What alert should have fired at 09:01, and why is "queue depth" the wrong one?
#
Time
Producers
Queue
Workers
Users
1
09:00
1,000 msg/s
0 waiting
config push: all 20 pods crash-loop
1 / 8

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.

Loading simulation…
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.

TypeShareIn the 2M backlogWhat to do with it
Passcodes, password resets3%60,000Send first. Drop any older than its validity (10 min): the code is dead, and the user has already asked for another
Receipts, shipping, webhooks77%1.54MSend in arrival order. Late is still useful
Digests, marketing20%400,000Delay, 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 from critical first.
  • 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:

  1. Why does one bad message take down every pod, not just one?
  2. What stops a message from being retried forever, and where must that counter live?
  3. What do the customers with digital orders get while you fix the template?
#
Time
Queue
Pod 1
Pod 2
Throughput
1
11:20:00
template v7 live
ok
ok
1,000/s
1 / 9

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.

Loading simulation…
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:

  1. Never let one message crash the process. Wrap the handling of each message in try/catch, including the asynchronous parts: await the renderer so its errors reach the catch instead of escaping as an uncaught exception. A message that fails must fail alone.
  2. Classify the error. A render error, a 400, or a 404 on a webhook URL is permanent: retrying will never work, so send the message to the DLQ on the first failure. A timeout, a 5xx or a 429 is transient: retry with exponential backoff and jitter, through a delay queue, not an immediate NACK. (Ch 2 has the table of what is retryable.)
  3. 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:

  1. Why is the password reset waiting behind marketing? Name the design decision that caused it.
  2. What does an immediate retry of a 429 do to the provider's count, and to your workers?
  3. How fast should the campaign go out? Give a number.
#
Time
Marketing
Queue
Workers
Email provider
One password reset
1
10:00
publishes 3M emails in 5 min
3M waiting
2,000 sends/s
1,200/s accepted
1 / 9
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, then transactional, then bulk.
  • 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.paid webhook arrived twice. The queue is at-least-once. Explain the duplicates and stop them."

Before you read on:

  1. List every way a message gets processed twice in this design. There are at least three.
  2. Can you make delivery exactly-once? If not, what can you promise?
  3. What do you need from the email provider and from webhook receivers?
#
Time
Worker A
Queue
Email provider
Worker B
User's inbox
1
12:00:00
receives #881
#881 hidden for 30 s
1 / 10
Answer

The three sources of duplicates:

SourceWhenSize today
Visibility timeout expires mid-sendProvider slower than the timeoutEvery send slower than 30 s
Worker dies after sending, before ackingCrash, OOM kill, deploy without drainingUp to 49 per crashed pod (Break 2: about 1,000 per crash wave)
Ack lost or failedNetwork error on the ack callRare, 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.

  1. A claim record before sending. One row per notification and channel:

    sql
    INSERT 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, set state = '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.

  2. Pass an idempotency key downstream. If the email provider accepts one, send notification_id, so that the remaining crash window (sent, then died before writing sent) is deduplicated on the provider's side. Not every provider supports this. Find out before you rely on it.

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

  1. How big is the backlog for Acme, and how fast can Acme take it?
  2. Why are other customers' webhooks late? Find the shared resource.
  3. How fast should you send to an endpoint that just recovered, and how do you find that out without asking?
#
Time
Acme endpoint
Acme backlog
Our workers
Other customers
1
14:00
down: their deploy failed
grows at 20/s
retry with backoff, capped at 1 h
ok
1 / 9

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.

Loading simulation…
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 rateSpare over Acme's 20/s of new traffic144,000 ÷ spare
50/s (their stated capacity)30/s4,800 s ≈ 80 min
Ramp 40 → 100/s, about 75/s on averageabout 55/sabout 45 min
200/s, if Acme scales up for the catch-up180/s800 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

ChangeMotivated byWhat it costs
Alert on oldest-message age and ack rate, per laneBreak 1Metrics per lane
Canary rollout for worker config and templatesBreaks 1, 2A slower rollout
Priority lanes: critical / transactional / bulkBreaks 1, 3A scheduler to own
expires_at per message, dropped and countedBreak 1Agreeing in advance on which types may expire
Per-message try/catch, permanent errors → DLQ at onceBreak 2Error classification code
Delivery cap in the queue (5) → DLQ, with owner, alert and replayBreak 2A DLQ that someone must watch
Fallback templatesBreak 2A second template per type
Client-side token bucket per provider at 90% of the limit, Retry-After pauses itBreak 3Campaigns take an hour
Weighted lane shares with a bulk floorBreak 3 follow-upTuning
Bulk published gradually, or a separate provider streamBreak 3A second sender account
Worker deadline 10 s, visibility 60 s plus heartbeatBreak 4Slow sends abandoned and retried
Claim row in deliveries, provider idempotency key, event id on webhooksBreak 4About 1,000 extra writes/s
Per-endpoint concurrency cap, AIMD rate, circuit breaker, per-destination backlogBreak 5Per-destination state for 5,000 endpoints
Outbox instead of "insert, then publish"All: no notification lost if the API dies between the twoA relay process (Q49)

Scoring Yourself

SkillStrongOKWeak
Recovery arithmeticUses 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 sendSeparates critical, transactional and bulk, expires dead passcodes, sends criticals in 30 sSuggests priority without saying what happens to expired messagesSends all 2M in arrival order
Poison messagesPer-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 limitsToken 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
DuplicatesNames three sources, says exactly-once is impossible across the provider, designs claim row + idempotency key + receiver dedupeIdempotency key on the API only"Our queue is exactly-once"
Downstream recoveryPer-endpoint bulkhead and breaker, AIMD drain, drain-time table, notices that a full token bucket is a burstRate-limits per endpointRetries everything as soon as the endpoint is back
Shape of the answersDetect → contain → degrade → recover → prevent, each with a number and a trade-offRight fixes, out of orderJumps 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

Self-test: predict each consequence before revealing it.
  • If arrivals continue during a consumer outage
  • If all notification types share one FIFO queue
  • If one message can crash the process
  • If a 429 is 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.