Engineering Atlas › System Design Fundamentals

Decoupling work with message queues

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Point-to-point and competing consumers

A producer sends a message and moves on. The queue stores it durably and hands it to one of N consumers that pull from it. Adding consumers adds throughput, because they compete for messages rather than each receiving a copy. That is the defining difference from pub/sub (see the Pub/Sub lesson), where every subscription gets its own copy.

This is the overload and partial-failure problem shape. A queue lets the slow or failing side of a system fall behind without the fast side noticing. The same structure appears in the Linux kernel: a listening socket has an accept queue sized by the listen() backlog (capped by net.core.somaxconn, 4096 by default since kernel 5.4). Handshaken connections wait there for accept(), and when it is full the kernel drops new ones. Both fail the same way: too small a buffer sheds work, too large a one hides an outage behind latency.

Lease, ack, redelivery

A message moves through a small state machine. It is ready after it is sent. A receive moves it to in flight, leased to one consumer until a deadline. SQS calls this the visibility timeout: 30 seconds by default, and extensions can never push it past 12 hours from the first receive (SQS visibility timeout). RabbitMQ calls the message unacknowledged and guards it with consumer_timeout, 30 minutes by default; when it fires, the broker closes the whole channel and requeues everything that channel held (RabbitMQ consumers). An ack deletes it. A nack, or an expired lease, makes it ready again and increments its receive count. After too many receives it goes to a dead letter queue (see the Dead Letter Queues lesson).

The broker cannot tell a slow consumer from a dead one, so once the lease runs out it redelivers. A merely slow consumer then finds its ack rejected while another consumer holds the same message. SQS standard queues go further and say a message may occasionally arrive twice even inside the visibility timeout. Duplicates are therefore part of the contract, which is why the Delivery Semantics lesson ends at at-least-once plus idempotent handlers (see the Idempotency lesson).

Ordering and its price

A FIFO queue with one consumer is strictly ordered and has the throughput of one consumer. Add a second and message 2 can finish before message 1; any retry also reorders.

The practical answer is ordering per key. SQS FIFO calls the key a message group ID: while any message from group order-42 is in flight, no other message from that group is handed out, but other groups flow freely. Kafka gets the same effect with partitions. The cost is head-of-line blocking: one slow or poisoned message stalls everything behind it in its group until it is acked or dead-lettered. Key by what truly needs order, such as one customer's ledger, not all payments. A FIFO queue without high-throughput mode is also capped at 300 calls per second per API action, or 3,000 messages per second with batches of 10 (SQS quotas).

Fairness is a different problem from order. In a queue shared by many tenants, one tenant's burst fills the backlog and everyone else waits behind it. Since July 2025 SQS standard queues treat a MessageGroupId as a tenant label: when one tenant holds far more in-flight messages than the rest, SQS hands out the other tenants' messages first, without promising any order (SQS fair queues). Elsewhere you get the same effect with one queue per tenant class and a worker that polls them in turn.

Flow control

Consumers pull, so the queue never pushes more than they ask for. Prefetch (RabbitMQ's basic.qos, or up to 10 messages per SQS receive) caps how many unacked messages a consumer may hold: too low and it idles between round trips, too high and one consumer hoards work while others starve. Long polling (SQS WaitTimeSeconds up to 20) holds an empty receive open until a message arrives, cutting empty responses and cost.

On the producer side, a bounded queue is backpressure: when full, it rejects or blocks the producer instead of growing forever. Google's SRE book gives the same advice for request queues inside servers: under steady load, keep the queue at half the thread pool or less, so excess work is rejected early instead of waiting until it is useless (Google SRE book).

Down the stack

Layer 1The contract

Once the broker acknowledges a send, the message survives a broker crash (for durable queues) and will be delivered to some consumer at least once. It stays until it is acked, exceeds its maximum receive count, or ages past retention (4 days by default in SQS, at most 14, per the SQS quotas). While leased, no other consumer receives it; after the lease expires it can be redelivered, so a consumer must finish and ack within the lease or extend it. Standard queues promise no order. FIFO queues promise order within a group, and only while consumers ack in order. Nothing promises exactly-once processing.

Layer 2The mechanism

Underneath, a queue is two structures: a ready list in arrival order, and an in-flight set indexed by lease deadline (a heap or timer wheel) so expired leases can be found without scanning. Each message carries an ID, a receive count, and a per-delivery token: SQS returns a new receipt handle on every receive, and only the latest handle can delete the message, which is how a slow consumer's late ack is rejected. RabbitMQ numbers deliveries per channel with a delivery-tag and acks with basic.ack or basic.nack with requeue.

Redis Streams show the structure plainly. XREADGROUP moves entries into the consumer group's pending entries list (PEL), with owner, delivery count and last-delivery time. XACK removes them. XAUTOCLAIM transfers entries idle longer than a threshold to another consumer, which is lease expiry done by the client (Redis Streams).

Kafka now has the same state machine. Share groups ("queues for Kafka", KIP-932) became production-ready in Kafka 4.2 in February 2026 (Kafka 4.2 release). Consumers in a share group read the same partitions at once. Each record they fetch is locked to them for 30 seconds by default and carries a delivery count. The consumer answers ACCEPT, RELEASE (try again) or REJECT (never again). After 5 deliveries by default the record is archived, not redelivered, and redeliveries can arrive after later offsets (KIP-932). That is a lease-and-ack queue on top of a log.

A relational database can be a queue too. With Postgres's SELECT ... FOR UPDATE SKIP LOCKED (since 9.5), each worker locks the first ready row nobody else holds, so workers do not serialise on the head of the table (Postgres SELECT). The ack is a DELETE in the same transaction as the work, which gives exactly-once effects when the work touches the same database.

Layer 3The machine

Every message costs at least two durable writes: the send, and the ack that deletes it. A log such as Kafka avoids the second by storing a consumer offset once per batch, which is a big part of why logs are cheaper per message. A durable send means an fsync: tens of microseconds on an NVMe drive with power-loss protection, and milliseconds on drives without it. Brokers therefore group-commit, batching many messages into one fsync, and producers batch sends to amortise round trips (an SQS batch carries up to 10 messages).

Queues are fastest when nearly empty. RabbitMQ classic queues page old messages to disk as the backlog grows, adding disk reads exactly when the system is behind. In Postgres, each lease UPDATE and ack DELETE leaves a dead tuple, so a long-running transaction that holds back vacuum makes every dequeue scan past dead rows.

Why it's built this way

  1. Producers and consumers fail, deploy and scale on different schedules, so a durable buffer sits between them.
  2. A consumer can crash halfway through work, and nobody can tell slow from dead, so delivery is a lease that expires instead of a delete on hand-off. That makes duplicates unavoidable.
  3. Each delivery and ack is a durable write and a round trip, so brokers group-commit, batch and prefetch.
  4. Strict global order would mean one consumer, so ordering is scoped to a key and its cost stays inside that key.
  5. Memory, disk and patience are finite, so queues get length limits, retention, and dead-lettering.

Diagram

stateDiagram-v2
  [*] --> Ready: send
  Ready --> InFlight: receive (lease starts)
  InFlight --> Acked: ack before deadline
  InFlight --> Ready: nack or lease expires
  InFlight --> InFlight: extend lease
  Ready --> Dead: receive count reached max
  Acked --> [*]
  Dead --> [*]

Worked example

A photo service generates thumbnails. Uploads average 300 per second and peak at 1,500 per second for 10 minutes after a marketing push. A job takes 200 ms on one core, and the fleet runs 100 single-core workers, so capacity is 100 × 5 = 500 jobs per second.

During the peak the queue grows at 1,500 − 500 = 1,000 messages per second, reaching 600,000 messages after 10 minutes. By Little's law, a message arriving at the end waits about 600,000 / 500 = 1,200 seconds, 20 minutes. Afterwards the spare capacity is 500 − 300 = 200 per second, so draining takes 3,000 seconds, 50 minutes. Depth alone (600,000) does not tell you whether that is acceptable; the age of the oldest message (20 minutes) does. If thumbnails must appear within 2 minutes, autoscale on age and be ready to triple the fleet to 300 workers within a minute or two, or throttle the push at the source.

The p99 job takes 2 seconds, so a 30-second visibility timeout leaves slack. A 3-second one would redeliver on every GC pause, doubling work exactly during the peak.

Cost on SQS standard at about $0.40 per million requests: 300 per second is 26 million messages a day. Sending, receiving and deleting one at a time is 78 million requests, about $31 a day. Batching 10 per call cuts that to roughly $3, plus empty receives, which long polling keeps small.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Managed queue (SQS, Cloud Tasks)No servers, scales without planning, DLQ built inPer-request cost, polling latency, limited orderingDecoupling services in one cloud
Broker queue (RabbitMQ, ActiveMQ)Low latency push, prefetch, rich routingYou run it; deep backlogs hurt; one queue has a throughput ceilingTask queues at moderate scale with routing needs
Database as queue (Postgres SKIP LOCKED)Enqueue in the same transaction as business data; no new systemVacuum and lock overhead past thousands per secondJobs tied to database writes, modest rates
Log used as queue (Kafka, Redis Streams)Replay, cheap per message, ordered partitionsConsumer groups cap parallelism at the partition count and have no per-message retry; Kafka share groups (4.2+) add both but give up orderHigh volume, ordered processing, replay needed

Pitfalls in production

Leadership lens

Check yourself

  1. A consumer finishes a job in 45 seconds with a 30-second visibility timeout. What happens to its ack and why?

    Show answer

    The lease expired at 30 seconds and the message became visible, so another consumer may already hold it with a new receipt. The old receipt is stale and the delete fails or is ignored, and the job runs twice.

  2. Why does adding consumers to a FIFO queue with a single message group not increase throughput?

    Show answer

    Only one message from a group can be in flight at a time, so the group is processed serially no matter how many consumers exist. Parallelism comes from having many groups.

  3. Why is a high prefetch count risky for slow, uneven jobs?

    Show answer

    One consumer reserves many messages while others sit idle, and if it crashes all of them wait for their leases to expire before anyone else can work on them.

  4. Why do message queues usually cost more per message than a log like Kafka?

    Show answer

    A queue tracks state per message, so each ack is another durable write and round trip. A log stores one offset per consumer group and commits it per batch.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →