<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"> Skip to content

Redis Queues vs Direct API Processing

When to do the work inside the request, when to queue it, and the four things that make a queue safe to retry.

Ankit Narang5 min read

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

WorkQueue it?Why
Create a record the response returnsNoThe user is waiting for exactly this
Send a confirmation emailYesNobody watches the send
Charge a card at checkoutNoThe outcome changes what the user sees next
Render an invoice PDFYesWanted, not awaited
Resize an uploaded imageYesDeferrable, and slow
Validate a formNoIt is the response
Embed a document for searchYesSlow, batched, retryable
Call an external service the response depends onNo — but bound itNeeds 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 see

Three 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.

tsworker.ts
// 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.

tsqueue.ts
{
  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 worse

That 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.
Related labSystems canvasClick any component for the reasoning behind it. Try it

Have a system like this to build?

I take products from architecture to production — and I will tell you honestly if I am not the right fit.

Start a project