Most backend work falls into two shapes: work a client is waiting on right now, and work that just needs to happen eventually. The producer-consumer model is the pattern behind that second shape. One part of a system produces units of work and hands them off; another part consumes them independently, on its own schedule; and a durable buffer sitting between the two absorbs the mismatch in their pace. Queues, workers, BullMQ, SQS, and even cron-triggered jobs are all specific implementations of this one underlying idea.
This page is the mental model behind the rest of the section - why decoupling producer from consumer matters, what a broker actually guarantees about delivery (and what it doesn't), and the vocabulary that the hands-on pages assume you already have. Queues Basics shows this pattern in working Express and BullMQ code; this page is about the shape underneath it.
A producer hands work to a durable broker instead of doing it inline, and one or more consumers (workers) pull that work off independently - decoupling the pace of request handling from the pace of processing.
Insight: Without that buffer, every slow or bursty piece of work has to happen inside the request that triggered it, which means user-facing latency inherits the worst case of whatever's slowest downstream.
When to Use: Work that's slow relative to a request's latency budget, work that should survive a crash or deploy, work with bursty arrival patterns that would otherwise overwhelm downstream systems, and work that legitimately doesn't need to finish before you respond to the caller.
Limitations/Trade-offs: You trade immediate consistency and simple debugging for resilience and independent scaling - a queued system has more moving parts, eventual rather than immediate completion, and delivery guarantees that are weaker than they sound.
Related Topics: idempotency at the consumer, retries and backoff, scheduling and cron, circuit breakers.
Picture a restaurant kitchen with a ticket rail between the dining room and the line. A waiter takes an order, writes a ticket, and clips it to the rail - then goes straight back to the next table without waiting for the food to be cooked. Cooks pull tickets off the rail whenever they finish their current dish, work through them in roughly the order they arrived, and nobody in the dining room is blocked on any single cook's pace. The rail is the buffer that makes it possible for order-taking and cooking to run at two completely different speeds without either one stalling the other.
That's the producer-consumer model. The producer is the part of your system that discovers a piece of work needs doing - an HTTP handler that just accepted a request, say - and its only job is to describe that work and hand it off. The broker is the durable rail itself: a queue that holds each unit of work (a job or message) until something is ready to process it, surviving a restart of either side. The consumer, often called a worker, is a separate process that pulls jobs off the broker and does the actual work, on its own schedule and often as a completely different deployable than the producer.
The important shift is that the producer stops waiting. An HTTP handler that enqueues a job can respond to the client in milliseconds - typically with a 202 Accepted and a job id as a receipt - instead of blocking on however long the real work takes. The client gets an immediate, honest answer ("I've accepted this"), and the actual processing happens on a completely decoupled timeline.
The core mechanical difference from a normal function call is that a producer doesn't get a return value - it gets a receipt. Something like this is the whole contract:
// Producer: hands off a description of work, gets an id back immediatelyconst job = await emailQueue.add("send-invite", { to, orgId });// job.id is a receipt, not a result - the work hasn't happened yet
What the broker promises about that job's delivery is the part people most often get wrong. Distributed queues offer three theoretical guarantees: at-most-once (a job might silently vanish, but never runs twice), at-least-once (a job is guaranteed to be attempted, but might run more than once), and exactly-once (each job runs precisely once, no duplicates, no losses). Almost every real broker - BullMQ, SQS, and most others - defaults to at-least-once, because exactly-once requires coordinating the broker and the consumer's side effects as a single atomic transaction, which isn't achievable across a network in the general case. This is why Idempotency Keys exists as its own page: at-least-once delivery pushes the responsibility for correctness onto the consumer, which has to treat "processed this job twice" as an expected case, not an edge case.
The mechanism that makes at-least-once work is a lease, sometimes called a visibility timeout: when a worker picks up a job, the broker hides it from other workers for a bounded window rather than deleting it outright. If the worker acknowledges completion before the lease expires, the job is removed for good. If it doesn't - because the worker crashed, or the process was killed mid-job, or the deploy rolled the pod - the lease expires and the broker makes the job visible again for another worker to pick up. That's the mechanic behind "at-least-once": the job comes back precisely because the broker can't distinguish a slow worker from a dead one.
Backpressure is the other side of decoupling. A queue smooths out bursts, but it isn't infinite - queue depth (how many jobs are waiting) is the signal that tells you whether consumers are keeping pace with producers. A steadily growing depth means consumers are falling behind, and unlike a synchronous system where that shows up immediately as timeouts, a queue can mask the problem for a while by absorbing the backlog - which is exactly why queue depth belongs on a dashboard next to worker concurrency, not left to surface as a mystery hours later.
Because producer and consumer are decoupled, they scale independently and along different axes. Producers - typically your API layer - scale with request volume; consumers scale with the volume and cost of the work itself, and a CPU-heavy consumer (image resizing) often needs a completely different instance shape than a lightweight one (sending an email). This is also where ordering gets subtle: a single-partition FIFO queue preserves strict order but caps throughput to one worker at a time for that partition, while a standard (non-FIFO) queue trades strict ordering for parallel consumption across many workers. Priority queues sit in the same tension - jumping urgent jobs ahead of the line is easy to add and easy to abuse, and an unbounded stream of "urgent" work will starve everything else exactly the way an unmanaged priority lane would in the kitchen.
Jobs that fail repeatedly need somewhere to go besides retried forever or silently dropped - a dead-letter queue (DLQ) catches messages that exceed their retry budget so a human can inspect what a "poison message" actually contained, rather than losing it or looping on it indefinitely.
Scheduling deserves a mention here because it's easy to think of as a separate concept when it's really the same model with a different trigger: a cron-triggered job is a producer whose event is "the clock reached this time" instead of "a user did this thing." Scheduling & Cron covers the operational wrinkle that comes with it - without a leader lock, every replica of a scaled-out producer fires the same scheduled job redundantly.
Broker style
Strength
Weakness
Best Fit
Redis-backed (BullMQ)
Low latency, rich job features (priority, delay, retries) out of the box
You run and operate Redis yourself; not built for cross-region durability
"A queue guarantees exactly-once processing." Almost none do by default - most guarantee at-least-once, which means your consumer has to be idempotent, not the broker.
"If the queue accepted the job, the work is basically done." Acceptance only means the job was durably recorded - it says nothing about whether or when a worker actually completes it.
"Queue depth growing a little is fine as long as it eventually clears." A queue that never fully drains during normal traffic is a leading indicator that consumers are undersized, not a self-correcting blip.
"FIFO ordering is the default behavior of a queue." Standard queues in most brokers deliberately trade strict ordering for parallelism; you have to opt into FIFO semantics and accept its throughput ceiling.
"Workers have to live in the same process or container as the API." The entire point of the model is that they don't - producer and consumer are typically separate deployables scaled on different signals.
What's the difference between a "queue" and a "broker"?
They're often used interchangeably, but strictly a broker is the piece of infrastructure (Redis, SQS, Kafka) that manages message storage and delivery, while a queue is one named channel of jobs within it - a single broker can host many separate queues.
Why not just process everything synchronously and scale the API instead?
Scaling API replicas helps with throughput, but it doesn't fix latency for individual slow operations, doesn't survive a crash mid-operation, and doesn't let you size compute differently for lightweight requests versus heavy background work.
What does "at-least-once delivery" actually mean in practice?
It means the broker guarantees a job will be attempted at least once, but a crash, timeout, or lease expiry can cause it to be attempted again - so your worker's side effects need to be safe to repeat, not just safe to run once.
How does a broker know a worker is still processing a job and hasn't crashed?
It doesn't, directly - it relies on a lease (visibility timeout): the worker must acknowledge completion before the lease expires, and if it doesn't, the broker assumes the worst and makes the job available to another worker.
Is exactly-once delivery possible at all?
Not in the general distributed sense - achieving it would require the broker and the consumer's side effect to commit as one atomic operation across a network, which isn't practically available. Systems that claim it are almost always doing at-least-once delivery plus deduplication.
What should I actually watch to know if my workers are keeping up?
Queue depth over time and job age (how long the oldest waiting job has been sitting there) - a flat or shrinking depth with low job age means consumers are keeping pace; a steadily climbing depth means they aren't.
Why do poison messages need a dead-letter queue instead of just retrying forever?
An unbounded retry loop on a job that can never succeed wastes worker capacity indefinitely and can starve healthy jobs behind it - a DLQ caps the retry budget and preserves the message for inspection instead of losing it.
Are priority queues safe to rely on?
They're useful in moderation, but every job marked "priority" is implicitly deprioritizing everything else - if too much traffic gets marked urgent, low-priority jobs can starve indefinitely, which defeats the purpose of having priorities at all.
Is a cron job a producer-consumer pattern too?
Yes - the scheduler acts as the producer, firing on a time-based trigger instead of a user event, and whatever executes the job is still a consumer pulling (or receiving) that unit of work.
Why does queue-based processing change how I think about failure?
Because failures become explicit and inspectable - a failed job sits somewhere (retrying, or in a DLQ) rather than disappearing into a 500 response the caller has to interpret and retry themselves.
Do producer and consumer need to be written in the same language or framework?
No - they only need to agree on the broker's protocol and the job payload's shape, which is one reason the model works well for polyglot systems where, say, a Node API enqueues work a separate worker service in another stack consumes.
When is a queue the wrong tool?
When the caller genuinely needs the result before responding - queuing adds a round trip and eventual-completion semantics that make sense for background work but add unnecessary latency and complexity to a request that's inherently synchronous.