Home → Engineering
Queue Pathologies
09:14 in the morning. There are 40,000 messages waiting in the order queue and the number is climbing. Services are up, CPU is comfortable, the database is fine. The cause is one thing: a single malformed message at the head of the queue.
- A poison message is retried forever. Without a dead letter queue, the queue locks up.
- Backoff without jitter puts the problem on a rhythm. Everyone retries at the same moment.
- In one queue, big work blocks small work. Open a separate lane or add parallelism.
- Order breaks. The strongest answer is designing messages that do not depend on order: send absolute values, not deltas.
- An unbounded buffer is not a solution, just a deferred out-of-memory crash.
1. The poison message: one line that locks the queue
An order message arrived and cannot be parsed. Maybe a field is missing, maybe
volume holds an empty string instead of a number, maybe the encoding is
broken. The consumer throws, does not ack, the message stays in the queue
and is redelivered.
Then the same thing happens. And again. And again. This is a loop spinning hundreds of times a second, with 40,000 orders queued behind it.
No service crashes. Health checks are green. CPU is normal. The only abnormal thing on the dashboards is queue depth — and if you are not watching that, you learn about the incident when a customer calls.
The cure: a dead letter queue
The rule is simple: if a message has been tried N times, stop trying and move it aside.
void handle(Message m) {
try {
processOrder(m);
ack(m);
} catch (PermanentError e) { // parsing, validation, business rule
toDlq(m, e); // ← no retry at all, straight aside
ack(m);
} catch (TransientError e) { // network, timeout, 503
if (m.attempts >= 5) {
toDlq(m, e);
ack(m);
} else {
m.attempts++;
toDelayQueue(m, backoff(m.attempts));
ack(m); // REMOVE from the main queue
}
}
}
Two distinctions matter, and most teams skip the first:
- A permanent error is never retried. A message that cannot be parsed will still not parse on the fifth attempt. Retrying just burns time and resources. Got a 400 from a server? Do not retry. A 503? Do.
- A message to be retried is removed from the main queue. It moves to a separate delay queue. Leave it in place and "retrying" becomes the same thing as blocking everyone behind it.
A dead letter queue nobody looks at is the same as throwing messages away — with the added comfort of thinking you took precautions. Three things are essential:
- A counter: a
dlq_messages_totalmetric. - An alert: notify on a single order landing in the DLQ. The count should be zero; even one is abnormal.
- Replay: a button that pushes fixed messages back into the main queue. Work that requires hand-written SQL does not get done at 3am.
2. Exponential backoff — and why jitter matters
Retrying immediately on a transient error makes things worse: if the other side is already struggling, a second request strains it further. The classic answer is to double the wait each time:
100 ms → 200 ms → 400 ms → 800 ms → DLQ
Correct, but incomplete. Consider: the bridge went down for a second and 800 orders failed at the same moment. All wait 100 ms and retry at the same moment. All fail again, all wait 200 ms and retry at the same moment again.
So instead of random load you have produced synchronised waves. The bridge, trying to recover, gets slapped by 800 requests at regular intervals.
long backoff(int attempt) {
long base = Math.min(BASE_MS * (1L << attempt), CAP_MS); // 100,200,400,800...
// "full jitter": random between 0 and base
return ThreadLocalRandom.current().nextLong(base + 1);
}
Those 800 orders now spread across 0–800 ms. The bridge sees steady load and can breathe. One line, and the effect shows up on the graphs immediately.
Prices move in seconds. Holding an order for 800 ms and retrying is reasonable; sending it 30 seconds later is not — the price the customer wanted is no longer there.
So order messages carry a valid_until field. Before retrying it is
checked: if it has expired, it is not retried but rejected and reported to the
customer. A correct answer that arrives late is a wrong answer.
3. Head-of-line blocking: big orders delay small ones
There is a single Redis stream and every order flows through it. An enterprise customer sends a 1000-lot order; it is split into 40 pieces across bridges and takes 2 seconds to process.
For those 2 seconds, 0.01-lot orders behind it wait. Average latency on the dashboard looks fine while the 99th percentile goes through the roof.
| Fix | How | When |
|---|---|---|
| Separate lane | Big orders to their own queue with their own consumer | Simplest and most effective. Try this first. |
| Partitioning | Partition by account id; one account's big order only blocks its own partition | When order per account must be preserved (details) |
| Parallelism | N consumers instead of one | When order does not matter; the cheapest fix |
| Chunking | Split big work into small messages | When the work divides; never bloats the queue |
What we did was a mix of the first and the fourth: orders above 100 lots moved to a separate lane and were chunked there. Small-order latency dropped from 2100 ms to 40 ms at the 99th percentile — the average had always been fine; the problem lived entirely in the tail.
4. Reordering: #3 first, #1 later
Partial fills are sent in order: #1 (10 lots), #2 (15 lots), #3 (5 lots). They arrive as #3, #1, #2. The cumulative calculation is momentarily wrong and the customer sees strange numbers on screen.
Why does it happen?
- The messages landed on different partitions and were handled by different consumers.
- #1 failed once and was retried, while #2 and #3 went past it.
- Different paths and different delays on the network.
Three fixes, weakest to strongest
Send all messages for the same order to the same partition (key:
order_id). One consumer then processes them in order. Simple and
effective — but the guarantee still disappears during a rebalance.
// Expected: 1. Arrived: 3.
if (m.seq > expectedSeq) {
pending.put(m.seq, m); // hold it aside
if (pending.oldestAge() > 200) { // waited more than 200 ms
warn("missing message: " + expectedSeq);
expectedSeq = pending.firstKey(); // skip, do not deadlock
}
return;
}
process(m);
expectedSeq++;
drainPending();
It works, but it has a flaw: while waiting for the missing message you are blocked too. So a timeout is essential, otherwise you recreate section 3's disease with your own hands.
// Fragile — order required, sends a delta
{ "order_id": 991, "fill_no": 2, "added_lots": 15 }
// Robust — order irrelevant, sends absolute values
{ "order_id": 991, "fill_no": 2,
"cumulative_lots": 25, "cumulative_amount": 27512.50,
"remaining_lots": 5 }
With the second one, what happens if #3 arrives first? You write its cumulative
value. Then #1 arrives, carries a lower fill_no, and is
ignored. The result is still correct.
UPDATE positions
SET cumulative_lots = :cumulative_lots,
last_fill_no = :fill_no
WHERE order_id = 991
AND last_fill_no < :fill_no; -- ← an old message cannot write
Same idea as a fencing token: tie correctness to order rather than timing, and put the order inside the data.
The difference is at the design level: a system sending deltas depends on every message arriving, in order; a system sending absolute values recovers with a single message. When designing a message schema, the question to ask is: "If I received only this message, could I reach the correct state?"
5. Backpressure: 50k ticks and a consumer that cannot keep up
The market got busy. 50,000 ticks a second are arriving and the streaming engine can handle 30,000. Where do the other 20,000 go? You have three options, and the third is not an option:
| Option | Result | When it is right |
|---|---|---|
| Drop | Data loss, but the system stays up | Price ticks, metrics, live feeds |
| Slow the producer | Latency rises, nothing is lost | Orders, fills, balances |
| Buffer without limit | Memory runs out, the process dies | Never |
The third is the most common, because it is the most invisible when writing code. Creating an unbounded queue is one line, and that line makes it uncertain when your system will die. Put an upper bound on every queue and decide consciously what happens when it fills.
For prices: not dropping, but conflating
There is a nice trick on the price side. If 40 ticks for EURUSD have piled up, there is no point processing all 40 — the customer will only see the last one. So instead of a queue, keep the latest value per symbol:
// Not a queue — one slot per symbol
ConcurrentHashMap<String, Quote> latest = new ConcurrentHashMap<>();
// Producer: overwrite, do not accumulate
latest.put(q.symbol, q);
// Consumer: read at whatever speed it can manage
for (var e : latest.entrySet()) {
publish(e.getValue());
}
Memory is fixed: one entry per symbol. It does not grow whatever the load, and the customer always sees the freshest price — missing the intermediate ticks was never a problem.
For orders: a bounded queue and an honest rejection
Orders cannot be dropped. But they cannot be buffered without limit either. The right behaviour when the queue fills is to reject the new order openly:
BlockingQueue<Order> queue = new ArrayBlockingQueue<>(10_000);
if (!queue.offer(order)) { // full
metrics.increment("orders_rejected_queue_full");
return error("SYSTEM_BUSY, please retry");
}
It sounds bad, but the alternative is worse: accepting the order and opening it 40 seconds later at a stale price. A fast rejection beats a late acceptance — at least the customer knows what happened and can decide.
What to watch
- Queue depth — rising means the consumer is falling behind.
- Age of the oldest message — a better signal than depth; it shows blocking directly.
- DLQ counter — should be zero on the order side, with an alert attached.
- Retry counter — a jump usually means something outside is broken.
- 99th percentile latency — not the average. Blocking is only visible here.
- Dropped tick count — even deliberate loss must be measured.
Checklist AI agent task list
- Are permanent and transient errors separated? (Permanent ones must never be retried.)
- Is there an attempt limit, and does it lead to a DLQ?
- Does the DLQ have a counter and an alert?
- Is there a replay path from the DLQ? (Hand-written SQL does not count.)
- Does the backoff include jitter?
- Is order expiry checked before retrying?
- Does big work block small work? Is there a separate lane?
- Do messages carry absolute values or deltas?
- Does every queue have an upper bound?
- What happens when the bound is hit — drop or reject? Was it chosen deliberately?
Conclusion
A queue is a cushion between systems. But a cushion is also a concealer: it hides the slowness behind it for a while, then hands you all of it at once.
That 40,000-message queue came from a single malformed order. The fix was fifteen lines. The real lesson was that those fifteen lines should have been written on day one — because a poison message is not a possibility, it is a certainty.