Reference design — A reference architecture, not a report from one specific system. Your constraints decide the details.
"Should this go through a queue?" gets answered by cargo cult more often than by analysis. Queues get added to endpoints that did not need them, and left out of the ones that did.
The question has a short answer and a longer one. The short answer: queue the work the user is not waiting for.
#The decision, concretely
| Work | Queue it? | Why |
|---|---|---|
| Create a record the response returns | No | The user is waiting for exactly this |
| Send a confirmation email | Yes | Nobody watches the send |
| Charge a card at checkout | No | The outcome changes what the user sees next |
| Render an invoice PDF | Yes | Wanted, not awaited |
| Resize an uploaded image | Yes | Deferrable, and slow |
| Validate a form | No | It is the response |
| Embed a document for search | Yes | Slow, batched, retryable |
| Call an external service the response depends on | No — but bound it | Needs a timeout and a fallback, not a queue |
The last row is the one people get wrong in both directions. If the response genuinely depends on a third party, a queue does not help — you still have to wait. What you need is a timeout, a retry budget, and a defined answer for when it fails. If the response does not depend on it, queue it and stop holding a connection open.
#What a queue actually buys you
Not speed. The total work is identical. What changes is who waits, and what breaks under load.
DIRECT QUEUED
request ──► work ──► response request ──► enqueue ──► response
│ │
└── slow dependency ▼
holds a connection worker ──► work
and a request thread │
└── slow dependency
holds nothing
the user can seeThree concrete properties:
- Bounded request latency. Response time stops being a function of your slowest dependency.
- Absorbing bursts. A spike becomes queue depth instead of a stack of timeouts.
- Retries that are actually safe — provided you did the work described in the next section.
#The four rules that make a queue safe
A queue you cannot retry safely is worse than no queue, because it converts transient failures into duplicated side effects.
#1. Idempotency is mandatory, not advisable
Every mainstream queue delivers at least once. A worker that crashes after doing the work but before acknowledging will process the same message again. This is not an edge case — it is normal operation.
// The job key is derived from what the job is about, not from when it ran.
await queue.add('invoice.render', { orderId }, { jobId: `invoice:${orderId}` });
// And the handler itself tolerates a repeat.
async function renderInvoice({ orderId }: Job) {
const existing = await invoices.findByOrder(orderId);
if (existing) return existing; // already done — no-op
const pdf = await render(orderId);
return invoices.create({ orderId, pdf }); // unique index on orderId
}Belt and braces: a deterministic jobId so the queue itself deduplicates, and a handler that tolerates a repeat, and a unique constraint in the database so the third line of defence is the one that cannot be bypassed by application code.
#2. Backoff must be exponential and jittered
Naive retries synchronise. A downstream service that fails for everyone at once gets retried by everyone at once, one second later — a thundering herd that guarantees the second failure.
{
attempts: 5,
backoff: { type: 'exponential', delay: 1_000 }, // 1s, 2s, 4s, 8s, 16s
// Jitter spreads the herd. Without it, every retry lands in the same
// millisecond as every other retry from the same incident.
settings: { backoffStrategy: (attempt: number) =>
Math.round(2 ** attempt * 1_000 * (0.5 + Math.random())) },
}#3. A dead-letter queue, and someone who looks at it
After the retry budget is spent, the message must go somewhere inspectable. Without a DLQ, a single poisoned message can block a partition, or silently vanish — and both are worse than a ticket.
The DLQ only works if its depth is alarmed. An unmonitored dead-letter queue is a bug graveyard.
#4. Visibility timeouts longer than the work
If the job takes ninety seconds and the visibility timeout is thirty, the queue hands the same job to a second worker while the first is still running it. You now have a concurrency bug that only appears under slow conditions — which is to say, during incidents.
#Why Redis, and when not to
Redis with a library like BullMQ is an excellent default: you probably already run Redis for caching and sessions, latency is sub-millisecond, and the operational surface is small.
Reach for something else when:
- Jobs must survive anything. A durable log or a managed queue with delivery guarantees you can point at in an audit.
- Multiple independent consumers need the same event. That is pub/sub with durable subscriptions, not a work queue — a queue delivers each message once, to one consumer.
- Queue depth exceeds memory. Redis holds the queue in RAM. A backlog of millions of large payloads is a memory-exhaustion event.
A practical middle path: keep the payload small. Enqueue an identifier, not the document. The worker fetches what it needs.
#Scaling on depth, not on rate
Workers should scale on queue depth and its rate of change, not on HTTP request rate. Depth rising faster than it drains is the earliest honest warning the system gives you — usually well before users notice anything.
Depth stable, low ───────── healthy
Depth rising slowly ╱╱╱╱╱╱╱╱╱ add workers
Depth rising fast ▲▲▲▲▲▲▲▲▲ a downstream dependency is degraded;
more workers will make it worseThat last case matters. If the queue is backing up because a third party is slow, adding workers increases pressure on the thing that is already failing. The correct response is to throttle, not to scale.
#Priority lanes
One FIFO queue means a ten-thousand-row import sits in front of every password-reset email behind it.
Separate lanes with separate worker pools. The fast lane handles small, user-visible jobs; the heavy lane handles bulk work and can be throttled independently under load. This is cheap to add early and awkward to retrofit once a single queue name is embedded in fifty call sites.
#The honest summary
Queues are not a performance feature. They are a decoupling feature, and they trade immediate consistency for bounded latency and absorbable bursts. That trade is usually correct for work nobody is waiting for and usually wrong for work someone is.
The cost is real: idempotency everywhere, a new failure mode in the DLQ, and a product surface that has to express "pending" honestly instead of pretending everything is instant.
The Systems canvas shows where the queue sits in a full architecture, including what happens to it when the components around it fail.
Key takeaways
Queue work the user is not waiting for. Do not queue work they are. At-least-once delivery means every consumer must be idempotent, without exception. A dead-letter queue turns a poisoned message from an outage into a ticket. Queue depth, not request rate, is the scaling signal for workers.