Skip to content

Backpressure in Distributed Systems

CoreConceptJuly 17, 20267 min read

An order-ingestion API writes to an in-memory queue, a worker pool drains it, and a payment provider on the other end gets slow. The queue keeps accepting every request because nothing tells the API to stop — so it grows until the process runs out of memory and crashes, taking every queued order with it. A slow consumer did not cause this outage; an unbounded buffer that hid the slowdown did.

This guide follows that same pipeline — API → queue → worker pool → payment provider. We will define what backpressure actually signals, compare the mechanisms that carry that signal (TCP windows, bounded queues, HTTP 429), separate backpressure from load shedding, propagate the signal across every hop instead of just one, trace the OOM failure an unbounded queue causes, and decide where to bound each part of a pipeline.

API to worker pool to payment provider: a slow hop needs a signal back upstream
API to worker pool to payment provider: a slow hop needs a signal back upstream

Backpressure is a signal, not a bigger buffer

When a producer is faster than a consumer, the gap between them has to go somewhere: it either accumulates in a buffer, or it has to slow the producer down. An unbounded buffer looks like it solves the problem — no requests are rejected, nothing errors — but it only delays the failure and changes its shape. Latency grows unboundedly as the queue backs up, and if the buffer lives in process memory, the failure eventually becomes an out-of-memory crash instead of a graceful slowdown.

Backpressure is an explicit signal from a slow consumer back to its producer saying "stop, or slow down" — turning an invisible, unbounded accumulation into a visible, boundable one. The producer that receives this signal gets to make a decision: block, queue up to a known bound, retry later, or reject the request outright. None of those outcomes is free, but all of them are bounded and observable, unlike an in-memory queue quietly growing until the OS kills the process.

The goal is not to eliminate the mismatch between producer and consumer speed — that mismatch is often real and temporary. The goal is to make it visible and bounded instead of hidden inside a buffer that has no ceiling.

Producer/consumer speed gap has to go somewhere — visible and bounded, or hidden and unbounded
Producer/consumer speed gap has to go somewhere — visible and bounded, or hidden and unbounded

Quick reference

  • A fast producer and slow consumer create a gap that must go somewhere.
  • An unbounded buffer hides that gap until it becomes a memory or latency crisis.
  • Backpressure is an explicit signal that lets the producer choose a bounded response.
  • Blocking, bounded queuing, retrying, and rejecting are all bounded; silent buffering is not.
  • Goal: make producer/consumer speed mismatch visible and bounded, not eliminate it entirely.

Remember this

Backpressure converts an invisible, unbounded buildup into a signal the producer can act on — the mismatch doesn't disappear, but its failure mode becomes a choice instead of an OOM.

Signaling mechanisms: from TCP windows to HTTP 429

TCP flow control is the canonical low-level example: every ACK carries a receive-window size telling the sender exactly how many more bytes the receiver can currently buffer. The sender is mechanically prevented from sending more than that — backpressure here is built into the protocol, not bolted on.

Reactive streams (RxJS, Project Reactor, Java Flow) generalize the same idea to application code: a consumer calls request(n) to pull exactly n items, and the producer is not allowed to push more until asked. This inverts the naive "producer pushes as fast as it can" model into a pull-based one, where slowness downstream mechanically throttles upstream.

Bounded queues with blocking or rejecting put are the simplest mechanism for in-process pipelines: a queue with a fixed capacity either blocks the producer thread until space frees up, or immediately rejects with a signal the producer must handle. HTTP 429 Too Many Requests with Retry-After is the application-boundary version — an explicit, client-visible instruction to slow down, instead of the server silently queuing the request internally.

Same mechanism at four layers: capacity limit plus an explicit signal at the limit
Same mechanism at four layers: capacity limit plus an explicit signal at the limit

Quick reference

  • TCP receive window: backpressure built into the transport protocol itself.
  • Reactive streams: consumer pulls request(n) items; producer cannot outrun the ask.
  • Bounded queue: fixed capacity, explicit block-or-reject when full.
  • HTTP 429 + Retry-After: application-boundary signal visible to the calling client.
  • Pick the mechanism at the layer where the mismatch actually happens — transport, in-process, or API boundary.

Remember this

Every real backpressure mechanism shares one shape: a bounded capacity and an explicit signal when it's reached — silence past that bound is what turns a slowdown into a crash.

Backpressure vs load shedding: slow down or drop

Backpressure preserves all the work by making the producer wait or retry — nothing is lost, but latency for everyone rises while the system catches up. Load shedding takes the opposite trade: when capacity is exceeded, it deliberately drops or rejects a portion of incoming work immediately, so that the work it does accept keeps its normal latency instead of degrading for everyone.

They are not competing solutions — they answer different questions. Backpressure answers "how do I keep the producer from overwhelming me" and is usually the first line of defense. Load shedding answers "what do I do once I'm already at capacity and slowing down further isn't acceptable," and it usually kicks in after backpressure's queue is already full.

A well-designed pipeline uses both in sequence: bounded queue accepts up to capacity (backpressure), and once truly full, the system sheds — rejecting new requests outright, or dropping low-priority ones first — rather than letting the queue's blocking behavior turn every caller's latency into the slowest possible outcome.

Backpressure up to capacity, then shed low-priority work once truly full
Backpressure up to capacity, then shed low-priority work once truly full

Quick reference

  • Backpressure preserves work but raises latency for everyone as the system catches up.
  • Load shedding drops some work immediately to protect latency for the work it keeps.
  • They compose: bounded queue (backpressure) up to capacity, then shed past it.
  • Priority-aware shedding drops low-value work first instead of failing uniformly.
  • Choose shedding when uniform slowdown is worse than losing some requests outright.

Remember this

Backpressure and load shedding are sequential defenses, not alternatives — bound the queue first, and decide what to shed only once that bound is actually reached.

Propagate the signal across every hop, not just one

A pipeline with backpressure at only one hop still fails — the mismatch simply relocates to whichever hop still has an unbounded buffer. If the worker pool applies backpressure toward the queue, but the queue itself is unbounded, the queue absorbs the entire mismatch silently and grows anyway. Backpressure has to propagate end to end: each stage must be bounded, and each stage's "I'm full" signal must reach the stage feeding it, all the way back to the system's edge.

This is why reactive-stream libraries chain request(n) through every operator instead of only at the final subscriber, and why a well-built HTTP gateway forwards a 429 from a downstream service as a 429 to its own caller instead of quietly retrying forever or buffering the request itself. Breaking the chain at any single hop reintroduces the exact unbounded-buffer risk the rest of the pipeline was built to avoid.

For asynchronous pipelines specifically, this usually means: bound the queue at every hop, size each bound from that hop's acceptable worst-case latency, and make each hop's full-signal cause the previous hop to slow down or shed — not silently retry into its own unbounded buffer.

One unbounded hop relocates the mismatch instead of solving it
One unbounded hop relocates the mismatch instead of solving it

Quick reference

  • Backpressure at one hop is meaningless if the hop before it has no bound.
  • The mismatch relocates to whichever buffer in the chain is still unbounded.
  • Propagate the 'I'm full' signal all the way to the system's edge, not just one layer in.
  • A gateway should forward a downstream 429 as its own 429, not silently absorb and retry.
  • Size each hop's bound from that hop's own acceptable worst-case latency.

Remember this

A pipeline is only as backpressured as its least-bounded hop — propagate the signal end to end, or the unbounded buffer just moves somewhere else in the chain.

Failure story: the queue that absorbed everything until it couldn't

Trigger. The payment provider that the worker pool calls for every order slows from ~80ms to ~4s during a provider-side incident. The worker pool's per-order processing time rises roughly 50x, so its effective consumption rate from the internal order queue drops by the same factor. Symptom: within minutes, API pod memory climbs steadily; 20 minutes in, pods start getting OOM-killed, taking every order still sitting in the in-memory queue down with them, and clients see a mix of hung connections and 502s.

Root mechanism. The order queue between the API and the worker pool was an unbounded in-memory array — no capacity limit, no backpressure signal to the API when the worker pool fell behind. The queue absorbed the entire producer/consumer mismatch silently, converting a downstream latency incident into an upstream memory-exhaustion crash, and destroying orders that had already been accepted and acknowledged to the client.

Evidence. The queue-depth metric (once added post-incident) would show unbounded, near-linear growth starting exactly at the payment-provider latency spike, with no plateau until the OOM kill. Recovery: restart the API with an emergency queue cap and start returning 429 past that cap; reconstruct lost orders from the client-side order confirmation timestamps and payment-provider records where possible. Prevention: bound the queue's capacity, return 429 { Retry-After } once full instead of silently accepting, alert on queue depth as a first-class metric (not just process memory after the fact), and give the queue durability (a persisted log, not just an in-memory array) so a crash does not also mean data loss for already-accepted work.

Failure: unbounded queue absorbed a payment-provider slowdown until the API OOM-killed
Failure: unbounded queue absorbed a payment-provider slowdown until the API OOM-killed

Quick reference

  • Trigger: downstream payment provider latency spike slows worker consumption ~50x.
  • Symptom: unbounded in-memory queue growth, then OOM kill, then lost in-flight orders.
  • Mechanism: no queue bound and no backpressure signal converted a slowdown into a crash.
  • Evidence: queue-depth metric climbing without a ceiling, correlated to the provider incident.
  • Prevention: bound the queue, signal 429 past capacity, alert on depth, and persist the queue.

Remember this

An unbounded queue doesn't prevent failure — it just changes a visible slowdown into an invisible one that ends in an OOM crash and lost work instead of a bounded 429.

Where to bound, and what to do when it's full

Bound every internal queue by default — an unbounded queue is a memory-exhaustion incident with a delay timer on it, not a safety margin. Size the bound from the hop's acceptable worst-case latency (queue depth × per-item processing time), not from a round number, and treat queue depth as a first-class alerting metric alongside latency and error rate.

Decide what happens at the bound from what the operation needs: request-response paths (an order API a user is waiting on) usually want to reject fast with 429/503 and Retry-After so the caller can retry or fail visibly, rather than block and let the caller's own timeout fire uselessly. Fire-and-forget or durable-queue-backed paths can often afford to block the producer briefly, since nothing user-facing is waiting synchronously.

For pipelines with multiple hops, verify propagation explicitly: trace one request through every queue and confirm each hop is bounded and that hitting one hop's bound produces a signal the previous hop actually handles — not a silent retry into its own unbounded buffer.

Choose reject-fast vs brief blocking from whether a caller is synchronously waiting
Choose reject-fast vs brief blocking from whether a caller is synchronously waiting

Quick reference

  • Bound every internal queue — no exceptions by default.
  • Size the bound from acceptable worst-case latency at that specific hop.
  • Request-response paths: reject fast with 429/503 + Retry-After.
  • Fire-and-forget / durable-queue paths: brief producer blocking is often acceptable.
  • Verify propagation end to end — one request, every hop, confirm each bound is real.

Remember this

Bound every queue, size the bound from real latency budgets, and choose reject-fast vs block based on whether a user is synchronously waiting — then verify the signal actually reaches every hop.

Key takeaway

Backpressure does not remove the mismatch between a fast producer and a slow consumer — it makes that mismatch visible and bounded instead of letting it accumulate silently until something breaks. TCP windows, reactive request(n), bounded queues, and HTTP 429 are all the same mechanism at different layers: a capacity limit plus an explicit signal when it's reached. Load shedding is the next line of defense once that limit is truly hit, and none of it works unless every hop in the pipeline is bounded, not just one.

Practice (30 minutes): take one queue in a system you know — a job queue, a message broker topic, an in-memory buffer between two services — and answer three questions in writing: what is its capacity bound (if any), what happens when that bound is reached, and does the answer to the second question actually reach the component upstream of it, or does it get silently absorbed? If any answer is 'unbounded' or 'not sure,' that is the exact gap the failure story above traced.

Share:

Related Articles

A checkout API runs three replicas across two availability zones behind a load balancer — on paper, no single point of f

Read

A shopping cart write to cart-77 needs to land on enough replicas that a later read is guaranteed to see it — but "enoug

Read

A 4-node session cache scales to 5 nodes to handle more traffic. With hash(key) % N, that single node addition changes a

Read

Keep learning

Follow a structured path or browse all courses to go deeper.