Messagingqueuepub/subconsumer groupacknowledgementvisibility timeout

Message Queues

A queue turns "call Service B now" into "hand Service B a message it will process when it can" — buying failure isolation and a buffer for bursts, at the price of ack/retry/visibility-timeout semantics, dead-letter handling and a backlog you must watch.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

When Service A calls Service B directly, B’s outage is A’s outage and B’s slowest second is A’s p99. Putting a durable queue between them lets A finish in the time it takes to enqueue (~1–5 ms), lets B be restarted or redeployed without losing work, and absorbs a 10× burst that B drains over the following minutes.

Direct call versus queue

Synchronous A → B is the simplest integration: one call, one answer, one place to look when it fails. It couples A’s availability to B’s and A’s latency to B’s. A → Queue → B decouples both: A’s request completes when the broker has persisted the message, and B consumes at its own rate. What A loses is the answer — it does not learn whether B succeeded — and what the system gains is a new place for work to silently pile up.

The queue is a buffer, and a buffer is only useful if the consumer eventually catches up. A queue in front of a consumer that is permanently slower than the producer is not decoupling; it is a delayed outage with a growing memory bill. Backpressure covers what to do when that happens.

Service A → Service B, directly or through a queue
A → B (synchronous)A → Queue → B
CouplingTemporal: B must be up and fast while A waitsOnly the message schema; B can be down for minutes
Latency for AA’s latency includes B’s (p99 stacks)Enqueue time, ~1–5 ms; B’s latency is invisible to A
Failure isolationB’s failure is A’s failure; retries from A amplify itB’s failure becomes a backlog; A is unaffected
BacklogNone — overload shows up as timeouts and 503s immediatelyUnbounded unless capped; overload shows up as oldest-message age
Result to AImmediate: success, rejection, or errorNone; A must poll a status or subscribe to a completion event
ComplexityOne call, one timeoutBroker, acks, visibility timeouts, retries, DLQ, lag monitoring

Acknowledgements, visibility timeout, retries, dead letters

A consumer receives a message and, when finished, acks it; the broker then deletes it. If the consumer crashes first, the message must come back. In SQS this is the visibility timeout: the message is hidden for, say, 30 s after delivery and reappears if no ack arrives. In RabbitMQ an unacked message is redelivered when the channel closes. Either way, delivery is at least once, and the consumer must be idempotent — the same rule as in Event-Driven Architecture.

A visibility timeout shorter than the work is a classic bug: a 45 s job on a 30 s timeout is delivered to a second worker at second 31, both finish, and the effect happens twice. Set the timeout above the p99 of the handler, or extend it from inside the handler while working. Retries need backoff with jitter, otherwise a broker outage ends with every consumer hammering the same dependency at the same instant.

A poison message fails every time — malformed payload, a bug in the handler for one input, a record that no longer exists. Without a limit it is retried forever and blocks the workers that keep picking it up. After N attempts (3–5 is common) the broker moves it to a dead-letter queue (DLQ). A DLQ is not a bin: alert on its depth, inspect the messages, fix the cause, and redrive them. A DLQ that nobody reads is data loss with extra steps.

One message through a queue with ack, retry and DLQ
enqueuedeliver, hide 30 ssuccesscrash / errorattempt < 5attempt = 5Service AQueueWorker (B)ack → deletedno ack → visible againDead-letter queueOn-call inspects, redrives
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Queues, topics, consumer groups — and which broker

A queue delivers each message to one consumer: ten workers on one queue share the work (competing consumers). A topic delivers each message to every subscription: pub/sub. A consumer group combines both — the group as a whole receives every message once, and members of the group split it. Kafka builds this in; with RabbitMQ you bind one queue per consumer to a fanout exchange; with AWS you subscribe several SQS queues to one SNS topic. The abstractions in a Queue and a Deque are the mental model: FIFO hand-off with a head that consumers pull from.

Conceptually there are three broker shapes. A RabbitMQ-style queue deletes on ack, routes richly (exchanges, routing keys, priorities), and is good for task distribution with per-message acks. SQS is the same shape as a managed, effectively unbounded service with visibility timeouts and a built-in DLQ; standard queues are unordered and may duplicate, FIFO queues order within a message group at lower throughput. A Kafka-style log never deletes on ack — consumers keep an offset, many groups read the same data, and history can be replayed; it is the right choice when several teams consume the same stream or when you need to rewind. Kafka-Style Logs: Topics, Partitions, Offsets covers it. Redis streams (Redis: Data Structures, Not a Cache) give a small log with consumer groups when you already run Redis and the volume is modest.

Key points

  • A queue converts B’s failure into a backlog and B’s latency into invisibility for A — decoupling with a bill.
  • Ack after the effect is durable; delivery is at-least-once, so handlers must be idempotent.
  • Visibility timeout above the handler’s p99, retries with backoff and jitter, a DLQ after 3–5 attempts, and an alert on DLQ depth.
  • Queue = one consumer per message; topic = every subscriber; consumer group = each group once, shared inside.
  • Queue for tasks, SQS when you want it managed, Kafka when several consumers need the same stream or replay.

A queue with ack, retry and dead-letter

A queue with ack, retry and dead-letter
Producers push, consumers pull and acknowledge. Tune the knobs, then advance time and watch where messages end up.
Retry policy
queue depth (ready)13
in flight (invisible until timeout)2
acked145
retried14
dead-lettered1
lost (no retry)0
duplicates (crash before ack)1 · 0 produced
After 3 attempts message #5 went to the dead-letter queue, where a human can inspect it; the rest of the traffic flows. Arrival (8/s) exceeds effective capacity (7.2/s), so the queue grows without bound; add consumers or shorten processing. Ack semantics: a message is removed only when the consumer acks it. If the consumer crashes after doing the work but before the ack, the message becomes visible again after 5 s and is processed twice — 1 time so far. That is why every handler must be idempotent.
t = 20 s · capacity 8.0 msg/s vs 8 msg/s in

How data moves through it

One request or event, hop by hop.

  1. 1Service A → Queue: SendMessage with the payload, a message id and a traceparent header; A returns 202 with a job id.
  2. 2Queue → Worker: long-poll delivers the message and starts the visibility timeout.
  3. 3Worker → Database / External API: perform the effect idempotently, keyed on the message id.
  4. 4Worker → Queue: ack (delete) after the effect is committed; on error, let the timeout expire or nack with a delay.
  5. 5Queue → DLQ: after the fifth failed attempt the broker moves the message aside and the depth alarm fires.
  6. 6Client → Service A: GET /jobs/{id} polls status, or a completion event notifies the client.

When to use — and when not

Use it when
  • Work the caller does not need finished before responding: emails, thumbnails, exports, webhook delivery.
  • A downstream that is slower, flakier or rate-limited relative to the producer — a payment provider allowing 50 req/s behind a checkout doing 400.
  • Bursty input that a fixed pool of workers should drain steadily.
  • Deploying or restarting the consumer must not lose or fail in-flight requests.
Avoid it when
  • The caller needs the result to build its response — a queue only adds a status-polling loop and latency.
  • The consumer is permanently slower than the producer; a queue defers the capacity problem, it does not solve it.
  • Strict global ordering across all messages is required; standard queues and multi-partition topics do not provide it.
  • Two services, low traffic, one team: a direct call with a timeout and a Circuit Breaker is less machinery.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

Cheap to enqueue and very effective isolation; the cost is a broker to run and a set of semantics (ack, timeout, retry, DLQ, lag) that must all be configured correctly.

How it fails

  • Visibility timeout shorter than the handler: the same message is processed by two workers and the effect happens twice.
  • A poison message with no retry cap is redelivered forever and starves other messages of workers.
  • The DLQ fills for a week, nobody is alerted, and the "lost" notifications are found during the postmortem.
  • Consumer throughput drops below producer rate after a feature launch; oldest-message age climbs to hours while every dashboard stays green — the challenge queue-backlog-never-drains.
  • Broker outage with retry-on-enqueue and no backoff: producers stampede the broker when it returns.

How it scales

  • Add consumers: throughput scales linearly until the downstream (DB, external API) becomes the bottleneck.
  • Autoscale workers on queue depth or oldest-message age, not on CPU — see Background Jobs and Workers.
  • Shard the queue by key when one queue’s ordering or throughput limit is hit; FIFO queues cap around hundreds to a few thousand msg/s per group.
  • A Kafka-style log scales by partitions and lets many groups read without duplicating the data.

How it interacts with databases, queues, caches, APIs and external systems

  • Database: the worker writes results and a processed_messages row in one transaction so redelivery is a no-op.
  • Queue/broker: RabbitMQ, SQS, Kafka, Redis streams — chosen by retention, replay, ordering and managed-vs-self-hosted needs.
  • Cache: a job status record in Redis lets the client poll without hitting the database.
  • External APIs: the queue meters calls to a rate-limited provider; the worker owns retries, backoff and Rate Limiting on its side.
  • API layer: the producer returns 202 Accepted with a status URL rather than 200 with a result.
Don't delegate understanding
The manifesto →