Lesson 11 concluded that at-least-once plus idempotent processing is the only workable combination. A message queue is where that combination stops being a choice and becomes a requirement: when a job can be redelivered at any moment, the consumer must be idempotent — otherwise every redelivery is another side effect.

This lesson's lab stands up a real producer/consumer on Redis Streams. Two measurements stand out. First, going from 1 consumer to 4 is only 2.68 times faster, not 4 — because the bottleneck has moved from the consumers to Redis itself. Second, acking at the wrong moment makes 352 jobs vanish without a trace: the number of recoverable jobs is 0 instead of 352.

ℹ️ The measurement machine
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Native arm64 containers (Node v22.23.2), Redis 7-alpine. The producer and the consumer are the same file, worker/queue.js (the role is switched with the ROLE variable), each container limited to 1 CPU, with no dependencies — only XADD, XREADGROUP, XACK and XPENDING.

The key metric is msActive: the time from the start until the last job was processed, excluding time spent waiting on an empty stream. Counting that time would make "rate" reflect the DURATION_MS parameter rather than real consumption capacity.

12.1 Why go asynchronous

Benefit Concretely The price
Decouple user latency from heavy work Answer in 20 ms, then process the video for 5 minutes in the background The user gets a 202 Accepted — you must give them a way to learn when it is done
Decouple producer and consumer The consumer can be down while the producer keeps accepting work; deploy the two independently One more piece of infrastructure to run, monitor and understand
Absorb bursts The queue buffers the peak — 10× traffic for 30 seconds brings nothing down The peak turns into a backlog, and backlogs have their own price (section 12.5)
Natural retries A failed job stays in the queue instead of being lost It forces the consumer to be idempotent (Lesson 11)
⚠️ Pitfall: queueing work whose result the user needs immediately
Going asynchronous solves the problem of server latency, not the problem of the user needing to know the result. If the user clicks "Pay" and you return a 202 Accepted, they are left guessing: did it work? was the card charged? can I click again?

The test question: can the user carry on without knowing the result? For a notification email, yes. For a ticket booking confirmation, no. If the answer is "no" then either do it synchronously, or invest in a mechanism for them to track the status — and that mechanism usually costs more work than the processing itself.
The same work — the difference is when the user is released SYNCHRONOUS receive process the video · 5 minutes — THE USER IS WAITING 200 OK the result is immediate but after a 5-minute wait The connection is held for 5 minutes · every layer's timeout must be > 5 minutes · one burst exhausts the connection pool ASYNCHRONOUS receive push onto the queue 202 · 20 ms the user is released HERE a worker processes the video in the background · 5 minutes In exchange: you must give them a way to learn when it is done The deciding question is NOT "is this work heavy" but "can the user carry on without knowing the result". Sending a notification email: yes. Confirming a ticket booking: no.
Going asynchronous does not make heavy work lighter — it only moves the waiting from the HTTP connection to the queue.

12.2 Ack semantics — and 352 vanished jobs

The entire reliability of a message queue rests on when you send the ack. An ack means "this job is done, stop redelivering it" — so sending it too early is throwing away your own ability to recover.

The mechanism behind that has a name: when the broker hands out a job it does not delete it but moves it to the pending list and starts a timer. If the ack arrives before the timer runs out, the job counts as done. If it does not — the worker died, or is simply too slow — the broker concludes the worker is broken and hands the job to somebody else. That waiting period is called the visibility timeout, and it is exactly what turns "the worker died" from data loss into a redelivery.

At-most-once (ack on receipt) At-least-once (ack after completion)
When the ack is sent Right after reading, before processing After processing succeeds
The worker dies mid-job The job is lost forever The job stays in the pending list and is redelivered
Can a job be processed twice? No Yes — so the consumer must be idempotent
Use it when Almost never — silently losing jobs is the worst kind of failure The correct default for nearly every case

The lab measures the damage. The consumer reads 500 jobs ahead at a time (prefetch 500) and each job takes 5 ms — so one batch takes about 2.5 seconds. We docker kill the consumer after 1.2 seconds, which is mid-batch:

Ack mode Recoverable jobs (pending) What it means
Ack on receipt (auto-ack the whole batch) 0 Around 352 jobs had been read but not processed — and had already been acked. They vanish without a trace: no error, no log, nobody knows
Ack after completion 352 Everything unprocessed sits in the pending list and can be taken back with XAUTOCLAIM
🔬 The number of jobs lost equals the prefetch size
The figure 352 is not a constant — it is whatever remained of the prefetch batch at the moment the worker died. Which means the worst-case damage formula is very compact: jobs at risk = prefetch size.

That turns prefetch from a performance parameter into a reliability parameter. A large prefetch reduces round-trips to the broker (so it is faster), but if you ack at the wrong moment it is also exactly how many jobs you lose every time a worker dies.

And here is where I had to correct myself while building the lab: the first version of ACK_MODE=on-receive acked each message individually just before processing it, and the result was nearly identical to the correct mode (pending 359 against 351). That is not the real trap in practice — the real one is auto-acking the whole batch right after reading, which is the default in quite a few clients. Once corrected, the gap appears: 0 against 352.
A worker dies mid-batch — acking at the wrong moment loses jobs without a trace ACK ON RECEIPT (auto-ack the whole batch) — at-most-once read 500 jobs ACK all 500 immediately 148 jobs processed 💥 the worker dies 352 jobs acked but NOT processed ⇒ the broker considers them done ⇒ never redelivered Measured: pending = 0 · 0 jobs recoverable · no error, no log, nobody knows ACK AFTER COMPLETION — at-least-once read 500 jobs process → ack each job 💥 the worker dies XAUTOCLAIM takes them back Everything unacked sits in the pending list · another worker picks it up after the visibility timeout Measured: pending = 352 · all recoverable · in exchange: a job may be processed TWICE Maximum jobs lost = THE PREFETCH SIZE. Prefetch turns from a performance parameter into a reliability parameter. And "a job may be processed twice" is exactly why at-least-once REQUIRES an idempotent consumer (Lesson 11).
There is no "neither lost nor duplicated" option — that is the Two Generals problem from Lesson 11, seen from the consumer's side.
worker/queue.js — the consumer loop
// BLOCK 1000: wait up to 1s if the stream is empty, instead of spinning and burning CPU.
const res = await redis.cmd('XREADGROUP', 'GROUP', GROUP, CONSUMER,
  'COUNT', String(BATCH), 'BLOCK', '1000', 'STREAMS', STREAM, '>');

for (const [id, fields] of entries) {
  const f = {};
  for (let i = 0; i < fields.length; i += 2) f[fields[i]] = fields[i + 1];

  // Dedup on event_id BEFORE causing any side effect. SET NX is atomic, so if two
  // workers receive the same event only one wins (the precondition of at-least-once).
  const fresh = await redis.cmd(
    'SET', `lab:q:seen:${f.event_id}`, '1', 'NX', 'EX', '3600');
  if (fresh !== 'OK') {
    await redis.cmd('XACK', STREAM, GROUP, id);   // already processed -> ack and skip
    continue;
  }

  await doWork(f);

  // ACK LAST. This single line decides the reliability of the whole system.
  await redis.cmd('XACK', STREAM, GROUP, id);
}

12.3 Poison messages and DLQs — and one thing usually said wrongly

A poison message is a job that can never be processed: a malformed payload, a reference to a deleted record, or one that hits a bug exactly. It fails, is redelivered, fails again — forever.

The standard treatment is a DLQ — short for dead letter queue. It is simply a second queue: once a job has failed more times than allowed, instead of letting it come back forever, you move it there and ack it on the main queue. The job is not lost, but it no longer disturbs the normal flow — it sits waiting for a human to look at it.

The lab pushes 5,000 jobs with one poison job every 500 (10 poison jobs in total), and one consumer:

Configuration Jobs completed Jobs failed Still pending In the DLQ
No DLQ 4,990 10 10 — stuck forever 0
With a DLQ (MAX_ATTEMPTS=1) 4,990 10 0 10
⚡ Correcting a common claim: a poison message does NOT block the queue here
"One poison message blocks every message behind it" is repeated constantly, but it only holds for an ordered log with sequential offsets — the Kafka style, where the consumer cannot commit the offset of the broken message and therefore cannot move on within that partition.

With Redis Streams it does not: the measurements above show the other 4,990 jobs processed normally. The broken message merely stays in the pending list. That makes the damage quieter: there is no incident to notice, just a number growing while nobody looks at it.

So the required work differs by broker type. For an ordered log: a DLQ is mandatory, otherwise one bad record stalls the whole pipeline. For Redis Streams: an alert on the pending-list length is mandatory, otherwise you will lose jobs without knowing — which is why section 12.5 calls queue depth the number one health metric.
⚠️ Pitfall: having a DLQ that nobody looks at
A DLQ is just a box. It solves "a broken message clogs the pipeline" but it does not solve "this message has not been processed". A DLQ with no alert and no owner will accumulate for six months, until the day someone opens it and finds 40,000 unprocessed orders.

Three things are needed at once: an alert when the DLQ is non-empty, an owner who looks at it, and a way to replay jobs after the bug is fixed. Missing the third is the most common reason people abandon their DLQ — with no way to replay, looking at it achieves nothing.

12.4 Ordering and consumer groups — adding a consumer breaks the order

First, the name in the heading. A consumer group is the mechanism that lets several consumers read one queue without treading on each other: the broker remembers which consumer each job went to, and each job is handed to exactly one member of the group. That is why the command in section 12.2 is XREADGROUP and not XREAD — adding a consumer to the group adds processing capacity rather than duplicating the work. The trade-off is what this section is about.

Ordering is only guaranteed within one partition (or one stream key). The moment you add a second consumer to scale, jobs are processed in parallel and there is no global order left.

The lab's scaling measurements show exactly that — plus something nobody expects:

Consumers msActive (time to consume 20,000 jobs) Total throughput Rate per consumer Speedup
1 1,764 ms 11,338/s 11,338/s 1.00×
2 1,200–1,202 ms 16,652/s ~8,300/s 1.47×
4 649–664 ms 30,375/s ~7,600/s 2.68×

Four times the consumers is only 2.68 times faster. And the "rate per consumer" column explains why: it falls from 11,338 to about 7,600. The bottleneck has moved from the consumers to Redis — Redis executes commands on a single thread, so four consumers are contending for one sequential resource.

🔬 A repeating lesson: horizontal scaling is linear only until the bottleneck moves
This is the fourth time the same phenomenon has appeared in this series. In Lesson 3, adding app replicas did nothing while the database was the bottleneck. In Lesson 5, single-flight did not improve p99 because the bottleneck was not there. In Lesson 8, two shards on one machine were slower than one. And here, four consumers are only 2.68 times faster because Redis is single-threaded.

The general rule: horizontal scaling is linear only until the bottleneck moves to a shared component. So before adding workers, answer this: are the workers really the bottleneck? The cheapest check is to look at the rate per worker — if it falls as you add workers, you are re-slicing a fixed cake, not baking a bigger one.

In the lab, the way past it is partitioning: several stream keys instead of one, with one consumer per key. That is also the mechanism for preserving order where it matters — see just below.
⚠️ Pitfall: assuming a global order
With two consumers, an OrderCancelled event can perfectly well be processed before the OrderCreated of the same order. The system lands in a nonsensical state: cancelling an order that does not exist yet, then creating an order that should already have been cancelled.

The fix is not to abandon scaling but to choose the partition key by entity: every event for the same order_id goes to the same partition, so they are always processed sequentially relative to each other, while different orders still run in parallel. You trade "global ordering" — which you do not need — for "ordering within an entity" — which you actually do.

This is also the technique from Lesson 10 section 10.5 under another name: partitioning by key makes the lock unnecessary, because each key is touched by exactly one worker.

12.5 Backpressure and queue depth

Queue depth is the number one health metric of an asynchronous system, and the reason lies back in Lesson 1: if the arrival rate $\lambda$ exceeds the service rate $\mu$ then depth grows without bound, and by Little's Law so does the waiting time. No load level is "safe" if $\lambda > \mu$ — there is only the time before the consequences show.

Queue depth over time — the number one health metric depth t burst starts burst ends λ < μ · depth ≈ 0 λ > μ · GROWS WITHOUT BOUND Little's Law: the wait grows with it consumers added here the backlog drains and returns to 0 The red line never saturates. No load level is "safe" when λ > μ — there is only the time before the consequences show. And the worst consequence is not slowness but work becoming MEANINGLESS: a confirmation email sent 6 hours late.
The shape of the three lines comes from the λ/μ relationship and Little's Law in Lesson 1 — this is a conceptual illustration, not lab measurements.
Situation Depth over time What the user experiences
$\lambda < \mu$ (enough consumers) Fluctuates around 0 Results arrive within seconds
A short burst, $\lambda \gg \mu$ for 30 seconds Spikes then returns to 0 Slow for a while — this is exactly what the queue is for
$\lambda > \mu$ continuously Grows without bound Results arrive hours later — for an action they long ago forgot about
⚠️ Pitfall: treating the queue as "unbounded, therefore safe"
A queue does not make an incident disappear — it changes the incident's shape. A 5-minute incident at $\lambda = 2\mu$ creates a backlog that takes another 5 minutes to drain. But if the consumers are down for 30 minutes while traffic keeps arriving, you have a backlog that by Little's Law will take hours to clear — and throughout that time, every new job also waits behind the old queue.

The worst consequence is not the delay but the work becoming meaningless: an "your order is confirmed" email sent 6 hours later, a notification about a livestream that has already ended, or an order-cancellation job running after the order was delivered.

Four relief valves, in the order to try them: scale the consumers (if the bottleneck really is there — see section 12.4); cap the queue length and reject new jobs beyond it, so you fail fast rather than promise; load shedding — drop unimportant jobs to protect important ones; and slow the producer down (rate limiting, Lesson 13). What all four have in common: they are all ways of saying no — and an asynchronous system with no way to say no is merely postponing saying it.

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile queue up -d redis

# ./tools/queue-test.sh COUNT N_CONSUMER [WORK_MS] [POISON_EVERY] [MAX_ATTEMPTS] \
#                       [IDEMPOTENT] [ACK_MODE] [DUR] [BATCH]

# --- Scaling: 20,000 jobs, 1 / 2 / 4 consumers. Read `msActive`, not `rate`. ---
./tools/queue-test.sh 20000 1 0 0 0 0 after 15000   # msActive 1764ms · 11,338/s
./tools/queue-test.sh 20000 2 0 0 0 0 after 15000   # msActive ~1201ms · total 16,652/s
./tools/queue-test.sh 20000 4 0 0 0 0 after 15000   # msActive ~658ms  · total 30,375/s

# --- Poison messages: 5,000 jobs, one poison job every 500 ---
./tools/queue-test.sh 5000 1 0 500 0 0 after 15000  # pending=10, stuck forever
./tools/queue-test.sh 5000 1 0 500 1 0 after 15000  # dlq=10 · pending=0

# --- Acking at the wrong moment: kill the consumer MID-BATCH of 500 jobs ---
docker compose exec -T redis redis-cli DEL lab:jobs
docker compose run --rm --no-deps -e ROLE=producer -e COUNT=3000 queueworker queue.js
docker compose run --rm --name qkill --no-deps -e ROLE=consumer -e WORK_MS=5 -e BATCH=500 \
  -e ACK_MODE=on-receive -e DURATION_MS=30000 queueworker queue.js &
sleep 1.2 && docker kill qkill
docker compose exec -T redis redis-cli XPENDING lab:jobs g1 | head -1
# prints 0  -> the 352 unprocessed jobs are gone for good
# Switch ACK_MODE to `after` and repeat: it prints 352, all recoverable

In summary

Going asynchronous decouples user latency from heavy work and absorbs bursts, but it changes the shape of the problem rather than removing it: the peak becomes a backlog, and backlogs have their own price.

All the reliability rests on when you ack. Measured with a prefetch of 500 and the worker killed mid-batch: ack-on-receipt gives 0 recoverable jobs, ack-after-completion gives 352. The maximum number of jobs lost equals the prefetch size exactly — so prefetch is a reliability parameter, not just a performance one. And because at-least-once means a job may be processed twice, the consumer must be idempotent (Lesson 11).

On poison messages, I corrected a claim that is usually stated wrongly: with Redis Streams a poison message does not block the messages behind it — the other 4,990 jobs ran normally, and the broken message merely stayed in the pending list. That makes the damage quieter, so for this kind of broker the mandatory thing is an alert on pending-list length, not just a DLQ.

Finally, four times the consumers is only 2.68 times faster — the fourth time in this series that the bottleneck moved to a shared component the moment we scaled out. The cheapest check is the rate per worker: if it falls as you add workers, you are re-slicing a fixed cake.

Lesson 13 covers the last relief valve on the list in section 12.5: rate limiting. It is the mechanism by which a system says no in a controlled way — instead of accepting everything and breaking, or accepting everything and promising a six-hour backlog.

📖 References

Download the lab source

The producer and the consumer in one file, speaking the Redis Streams protocol directly with no library. All three measurement modes in this lesson — scaling, poison/DLQ, and acking at the wrong moment — are toggled by environment variables in this file:

Download queue.js (Redis Streams, 0 dependencies)

Related lessons in this series

Lesson 11: Idempotency & Safe Retries Lesson 13: Rate Limiting & Backpressure Back to the System Design roadmap

Comments