#Why queues
AddedA message queue sits between the part of a system that notices work and the part that does it. The producer writes a message ("order 812 was placed, send the confirmation") and carries on; a consumer picks the message up when it's ready, does the work, and acknowledges it. Until that acknowledgement, the broker keeps the message. That one small indirection changes four things at once:
- Decoupling. The producer doesn't need the consumer to be up, fast, or even known. The checkout API keeps taking orders while the email service is being deployed. Teams can add a new consumer without touching the producer.
- Buffering bursts. Work that arrives faster than it can be done waits in the queue instead of being refused. You size consumers for the average load plus headroom, not for the worst minute of the year.
- Retries for free. A consumer that crashes halfway never acknowledges, so the broker hands the message to someone else. Failure turns into delay instead of loss.
- An asynchronous user experience. The API answers
202 Acceptedwith a job id in milliseconds, and the slow part (transcoding a video, generating a PDF, calling a flaky partner) happens afterwards, with progress pushed or polled.
A good mental model is the ticket rail in a restaurant kitchen. The waiter clips each order to the rail and goes straight back to the tables; the cooks pull tickets at their own pace. A rush of forty orders doesn't make the waiter stand at the stove: the tickets just pile up on the rail, and if a cook burns a dish the ticket is still there to redo. But the rail doesn't make the kitchen any faster. If orders keep coming faster than the cooks can cook, the rail grows until customers give up and leave. A queue absorbs bursts; it doesn't fix sustained overload (that's Backpressure and load shedding).
The model below puts numbers on the buffering. A shop normally gets 300 orders a second; a flash sale sends 1,500 a second for ten seconds. Each worker confirms 100 orders a second. How deep does the queue get, how long does the oldest order wait, and what does adding workers do?
// A flash sale: 300 orders/s normally, 1,500/s for 10 seconds. Each worker confirms 100 orders/s.
const arrivals = (t) => (t >= 10 && t < 20 ? 1500 : 300);
function simulate(workers, seconds = 160) {
const capacity = workers * 100;
const queue = []; // FIFO of [arrivalSecond, count] cohorts
let depth = 0, peak = 0, maxWait = 0, drainedAt = null;
const samples = [];
for (let t = 0; t < seconds; t++) {
queue.push([t, arrivals(t)]);
depth += arrivals(t);
let budget = capacity; // messages the workers can take this second
while (budget > 0 && queue.length) {
const head = queue[0];
const take = Math.min(budget, head[1]);
head[1] -= take; budget -= take; depth -= take;
maxWait = Math.max(maxWait, t - head[0]); // whole seconds spent waiting
if (head[1] === 0) queue.shift();
}
peak = Math.max(peak, depth);
if (t >= 20 && depth === 0 && drainedAt === null) drainedAt = t;
if ([5, 10, 15, 20, 30, 60, 100, 130].includes(t)) samples.push(`t=${t}s:${depth}`);
}
console.log(`${workers} workers (${capacity}/s): peak depth ${peak}, longest wait ${maxWait}s, empty again at t=${drainedAt}s`);
console.log(` depth ${samples.join(" ")}`);
}
simulate(4);
simulate(8);
simulate(16);
// The same burst with no queue: a synchronous service with 4 workers' capacity turns away the excess.
const rejected = 10 * (1500 - 400);
console.log(`no queue, 400/s capacity: ${rejected} orders rejected or timed out during the burst`);4 workers (400/s): peak depth 11000, longest wait 28s, empty again at t=129s
depth t=5s:0 t=10s:1100 t=15s:6600 t=20s:10900 t=30s:9900 t=60s:6900 t=100s:2900 t=130s:0
8 workers (800/s): peak depth 7000, longest wait 9s, empty again at t=33s
depth t=5s:0 t=10s:700 t=15s:4200 t=20s:6500 t=30s:1500 t=60s:0 t=100s:0 t=130s:0
16 workers (1600/s): peak depth 0, longest wait 0s, empty again at t=20s
depth t=5s:0 t=10s:0 t=15s:0 t=20s:0 t=30s:0 t=60s:0 t=100s:0 t=130s:0
no queue, 400/s capacity: 11000 orders rejected or timed out during the burst- Every simulated second, that second's arrivals join the back of the queue as one cohort (
[arrivalSecond, count]), and the workers take up tocapacitymessages from the front. Because the oldest cohort is served first,t - head[0]is how long the message being taken has waited. - With 4 workers (400/s) the normal 300/s never queues (
t=5s:0). During the burst the queue grows by1,500 - 400 = 1,100every second:1,100at t=10,6,600at t=15, and a peak of11,000at the end of t=19. - After the burst only the spare capacity drains the backlog:
400 - 300 = 100per second.11,000 / 100 = 110seconds, so the queue is empty again at t=129, and an order placed at the end of the burst waited 28 seconds (11,000 messages ahead of it at 400/s is 27.5 s, rounded up by the one-second ticks). - With 8 workers (800/s) the peak is
7,000and the spare capacity is800 - 300 = 500/s, so it drains in 14 seconds (empty at t=33) and the longest wait is 9 s. Doubling the workers cut the drain time from 110 s to 14 s, about 8 times, because drain speed is capacity minus arrivals, which went from 100 to 500. - With 16 workers (1,600/s) nothing ever queues, but outside the burst those workers run at
300 / 1,600 = 19%utilisation. That's the choice a queue gives you: pay for peak capacity all year, or pay with a few seconds of delay during the peak. - The last line is the same burst with no queue: a synchronous service with 400/s of capacity has to reject or time out the other 11,000 orders. With the queue nothing was lost, only delayed. That trade is right for confirmation emails and wrong for "was my card accepted?", which the user is waiting on.
Little's law says that, on average, the number of things in a system equals the rate they arrive times how long each one stays: L = λ × W. For a worker pool, "things in the system" are busy worker slots, so it tells you how many workers you need before you've written any code.
// Sizing a worker pool with Little's law: work in progress = arrival rate × time per job.
const jobsPerSecond = 2_000; // average arrival rate at peak hour
const secondsPerJob = 0.15; // a worker spends 150 ms per job (mostly waiting on I/O)
const targetUtilisation = 0.7; // leave headroom so the queue doesn't grow on every wobble
const slotsPerWorker = 20; // concurrent jobs one worker process handles (async I/O)
const busySlots = jobsPerSecond * secondsPerJob;
const slots = Math.ceil(busySlots / targetUtilisation);
const workers = Math.ceil(slots / slotsPerWorker);
console.log(`busy slots on average: ${busySlots}`);
console.log(`slots at ${targetUtilisation * 100}% utilisation: ${slots} -> ${workers} worker processes`);
// How long does a backlog take to clear? Only the spare capacity drains it.
const capacity = workers * slotsPerWorker / secondsPerJob; // jobs per second
const backlog = 600_000; // e.g. 5 minutes of a downstream outage at 2,000/s
const spare = capacity - jobsPerSecond;
console.log(`capacity ${Math.round(capacity)}/s, spare ${Math.round(spare)}/s`);
console.log(`a ${backlog.toLocaleString("en-GB")} backlog clears in ${Math.round(backlog / spare)} s (${(backlog / spare / 60).toFixed(1)} min)`);busy slots on average: 300
slots at 70% utilisation: 429 -> 22 worker processes
capacity 2933/s, spare 933/s
a 600,000 backlog clears in 643 s (10.7 min)2,000 jobs/s × 0.15 s=300jobs in progress at any moment, on average. That's the minimum number of concurrent slots that could possibly keep up.- Running at 100% means the queue grows on every small wobble in the arrival rate, so divide by a target utilisation of 70%:
300 / 0.7= 428.6, rounded up to429slots. With 20 concurrent jobs per process (async I/O), that's22worker processes. - Capacity is
22 × 20 / 0.15= 2,933 jobs/s. The spare capacity,2,933 - 2,000 = 933/s, is the only part that can work off a backlog. - A five-minute downstream outage at 2,000/s leaves
600,000jobs queued. They clear in600,000 / 933= 643 s, about 10.7 minutes. So the recovery time after an outage is set by the headroom you chose, which is the number to quote when someone asks "why not run at 95% utilisation?".
When work moves onto a queue, the API changes shape. Instead of returning the result, it returns a handle to the work:
POST /exports { "format": "pdf", "range": "2026-Q3" }
202 Accepted { "jobId": "exp_81f2", "status": "queued" }
Location: /exports/exp_81f2
GET /exports/exp_81f2
200 OK { "status": "running", "progress": 0.4 }
200 OK { "status": "done", "url": "https://files.example.com/exp_81f2.pdf" }POST /exportsvalidates the request, writes a job record, enqueues a message with the job id, and returns202 Acceptedimmediately. 202 means "accepted for processing, not finished", which is exactly the promise being made.- The
Locationheader andjobIdgive the client something to ask about. The job record lives in a database, not only in the queue, because the queue holds the work while the database holds the status users can query. - The client polls
GET /exports/exp_81f2, or better, gets told: a WebSocket or SSE push, a webhook for server-to-server clients, or an email with the link for long jobs (see Real-time options: polling, long polling, SSE, WebSockets). - If the client retries the
POSTbecause the 202 got lost, it creates a second export. Accept anIdempotency-Keyheader and return the existing job for a repeated key (see Idempotency keys for APIs).
- The caller needs the answer now. Login, a price check, "is this username free?", reading data. Putting a queue in front of a read only adds latency and a second place for things to go wrong.
- The overload is permanent. If arrivals exceed capacity on average, a queue grows without limit and every message gets later. Fix capacity or shed load.
- The system is small. A table in your existing database with a
statuscolumn andSELECT … FOR UPDATE SKIP LOCKEDis a perfectly good queue for thousands of jobs a minute, and it commits atomically with your business data. A broker is one more cluster to run, secure and monitor. - You'd lose more than you gain in debuggability. A synchronous call has one stack trace; an asynchronous flow needs correlation ids in every message, tracing across the broker, and dashboards for depth and age.
After an outage a queue holds 36,000 messages. New messages keep arriving at 1,000/s, and you have 12 workers that each handle 100/s. How long until the backlog is gone, and what does adding 3 workers change?
Show answer
180 seconds with 12 workers; 72 seconds with 15.
- 12 workers handle
12 × 100 = 1,200/s, but 1,000/s of that is needed just to keep up with new arrivals. - The spare capacity is
1,200 - 1,000 = 200/s, so the backlog drains in36,000 / 200 = 180s, three minutes. - With 15 workers capacity is 1,500/s and the spare is 500/s:
36,000 / 500 = 72s. - Adding 25% more workers made the drain 2.5 times faster, because it multiplied the spare capacity by 2.5. That's why a little extra headroom matters so much for recovery time.