FjellSystem Design Lab
Lesson 7.3• 9 min read

Backpressure, queue lag and dead-letter queues

Mathematical queue drain times, rate-limiting upstream producers, and isolating poison pills.

What you will learn

  • Predict whether a queue grows or drains from the arrival rate and consumer capacity.
  • Work out how long a backlog takes to drain after a spike.
  • Isolate poison pills with backoff, a retry limit and a dead-letter queue.

Queue lag dynamics and backlog accumulation

Let λ\lambda be the inbound message arrival rate (messages/sec), NN be the active consumer worker count, and μ\mu be the average processing capacity per worker (messages/sec).

The net queue accumulation rate ΔQ\Delta Q is defined as:

ΔQ=max⁡(0,λ−N⋅μ)\Delta Q = \max(0, \lambda - N \cdot \mu)
  1. Equilibrium (λ≤N⋅μ\lambda \le N \cdot \mu): Consumers keep pace with ingestion; queue depth remains near zero.
  2. Deficit (λ>N⋅μ\lambda > N \cdot \mu): Queue lag accumulates at a rate of λ−N⋅μ\lambda - N \cdot \mu messages/second.
  3. Drain Time (TdrainT_{\text{drain}}): If an initial backlog L0L_0 accumulates during a traffic spike, the time required to restore the queue to zero once arrival drops to λpost\lambda_{\text{post}} is:
Tdrain=L0N⋅μ−λpostT_{\text{drain}} = \frac{L_0}{N \cdot \mu - \lambda_{\text{post}}}

This only works when N⋅μ>λpostN \cdot \mu > \lambda_{\text{post}}; otherwise the backlog never drains and keeps growing.

Back pressure: An ever-growing queue can outgrow memory, causing cache misses, disk reads and even slower processing. Limiting the queue size keeps throughput and response times good for the jobs already in it. Once the queue is full, new requests are refused (for example with HTTP 503), and clients retry later, ideally with exponential backoff.


Poison pills and Dead-Letter Queue (DLQ) routing

A poison pill is a corrupted, unparseable, or schema-invalid message that causes worker code to throw an unhandled exception every time it is read:

Read Message⟶Throw Exception⟶NACK⟶Requeue⟶Read Again (Infinite Loop)\text{Read Message} \longrightarrow \text{Throw Exception} \longrightarrow \text{NACK} \longrightarrow \text{Requeue} \longrightarrow \text{Read Again (Infinite Loop)}

How much damage it does depends on the broker. In an ordered log such as a Kafka partition, a consumer that keeps retrying the bad message never moves past it, so everything behind it waits (head-of-line blocking). In a queue such as SQS, a message that is not deleted is received again later (SQS moves standard-queue messages received three or more times to the back of the queue), so other messages keep flowing, but the bad one keeps coming back and wasting work.

The DLQ pattern:

  1. Capped exponential backoff with jitter: Retry transient failures with waits that grow exponentially up to a cap, and add randomness so many clients do not retry in lockstep. Marc Brooker's "full jitter" picks the wait at random between zero and the capped exponential value:
    tsleep=random(0,min⁡(M,B⋅2i))t_{\text{sleep}} = \text{random}(0, \min(M, B \cdot 2^i))
    where BB is the base delay (e.g. 100 ms100\text{ ms}), MM the cap (e.g. 30 s30\text{ s}), and ii the attempt number.
  2. Max retry threshold: After a set number of failed attempts, move the message to a dead-letter queue (DLQ) with its error details. In SQS this is the redrive policy's maxReceiveCount: set it high enough to allow genuine retries. (A DLQ breaks strict ordering, so avoid one where the exact order of messages matters.)
  3. Unblocked pipeline: Healthy messages keep flowing. Engineers inspect the DLQ, fix the bug, and move the messages back for reprocessing (SQS calls this redrive).

Backpressure, drain time and a dead-letter queue

Ingestion rate (λ)15,000 msgs/s
Worker fleet (N)12 workers
Queue backlog45,000 msgs
Worker capacity (μ × N)
12,000 msgs/s
Net traffic flow
+3,000 msgs/s (Lag)
Estimated drain time (T_drain)
∞ (Overflowing)
Dead-Letter Queue (DLQ) (2 failed messages)
Unblocked
msg_err_801
JSONParseException: Unexpected EOF at byte 104
Payload: {"video_id": "v_992", "codec": "av1", "corrupt_d...
msg_err_802
NullPointerException: user_profile.billing_address is null
Payload: {"user_id": "u_108", "event": "invoice_email"}...
Worker processing & error backoff trace
Backpressure controller initialized. Monitoring queue lag metrics.
Figure 3: Backpressure control loop with consumer autoscaling, exponential backoff retries, and dead-letter queue routing.
  • Burst Traffic Producers: 1 × Clients & Users
  • Rate-Limiting API Gateway: 2 × Application Load Balancer
  • Main Ingestion Queue: 3 × Message Queue / Kafka
  • Autoscaling Worker Fleet: 6 × App Compute Server
  • Dead-Letter Queue (DLQ): 2 × Distributed NoSQL Store
  • Durable Media Sink: 2 × Object Storage (S3)
  • Burst Traffic Producers sends requests to Rate-Limiting API Gateway
  • Rate-Limiting API Gateway sends requests to Main Ingestion Queue
  • Main Ingestion Queue sends requests to Autoscaling Worker Fleet
  • Autoscaling Worker Fleet sends requests to Durable Media Sink
  • Autoscaling Worker Fleet sends requests to Dead-Letter Queue (DLQ)

Key takeaways

  • A backlog grows at λ−Nμ\lambda - N \mu when arrivals beat capacity, and drains in L0Nμ−λpost\frac{L_0}{N \mu - \lambda_{\text{post}}} only if capacity exceeds the later arrival rate.
  • Back pressure caps the queue and refuses new work when it is full, so the jobs already queued stay fast.
  • A message that always fails blocks an ordered partition or loops forever; after a set number of attempts, move it to a dead-letter queue.
  • Retry transient failures with capped exponential backoff and full jitter, so clients do not retry in lockstep.

Sources

Adapted from The System Design Primer by Donne Martin, licensed under CC BY 4.0. Changes were made.