Hermes Wiki
Architecture/Fundamentals/backpressure-and-flow-control

Backpressure and Flow Control

Concept

When a fast producer sends work to a slower consumer, the mismatch has to go somewhere. If nothing regulates the flow, the excess accumulates — in queues, buffers, socket receive windows, memory — and grows without bound until the consumer runs out of memory and crashes, or latency balloons as requests sit in an ever-deepening queue. Backpressure is the mechanism by which a overwhelmed consumer signals upstream to slow down, so the producer's rate is matched to the consumer's actual capacity rather than its own eagerness.

The key idea is that backpressure makes capacity limits explicit and propagating. Instead of a buffer silently swelling until it fails, the consumer says "I can take N more items" and the producer respects that. This can be implemented several ways:

  • Blocking / bounded buffers — a queue with a fixed capacity; when full, put blocks the producer, which naturally throttles it (the simplest form: a BlockingQueue).
  • Pull-based / demand signaling — the consumer explicitly requests n items and the producer sends at most n (the Reactive Streams model: request(n)). The consumer drives the rate.
  • Credit-based flow control — the consumer grants "credits" representing buffer space; the producer spends a credit per message and stops when credits run out (used in TCP's sliding window, HTTP/2, and messaging systems).
  • Rejection / load shedding — when the consumer truly can't keep up, it rejects excess work fast (429s, dropped items) rather than queuing it — a valid backpressure response when slowing the producer isn't possible.

The essential contrast is with unbounded buffering, which looks like it handles bursts (nothing is rejected) but merely defers the failure: the buffer grows, memory and latency climb, and the system fails later and harder than if it had pushed back early. A bounded system that pushes back is more resilient than an unbounded one that absorbs — "absorb everything" is not a strategy, it's a delayed crash.

Tradeoffs

Strategy Behavior under overload Cost
Unbounded buffer Absorbs bursts, then OOM/latency collapse Hides the problem until catastrophic failure
Bounded buffer + block producer Producer throttled to consumer rate Producer must tolerate blocking; can back up further upstream
Pull-based demand (Reactive Streams) Consumer paces producer precisely More complex API; whole pipeline must honor demand
Credit-based (windowing) Smooth, bounded in-flight work Tuning window size; protocol complexity
Load shedding (reject fast) Sheds excess, protects the consumer Some requests fail — needs graceful client handling

The central tradeoff is where the pain goes. Backpressure doesn't make excess load disappear — it relocates the problem from "consumer crashes" to "producer is throttled" (or "some requests are rejected"). That's almost always the better place for the pain: a throttled producer or a fast 429 is recoverable; an OOM crash and the cascading failure it triggers is not. But backpressure that propagates all the way up an entire pipeline can eventually reach the original client, so the end-to-end design must decide what happens there — block, buffer within a bound, or shed.

When to use / when not to

  • Use in any streaming or pipeline system where producer and consumer rates can diverge — Kafka consumers, reactive streams, message-driven microservices, data ingestion pipelines.
  • Use bounded queues and buffers everywhere by default; treat an unbounded queue as a latent out-of-memory incident waiting for the right traffic spike.
  • Pair with load shedding at the edge (reject fast with 429/503) for request/response systems where you can't slow the caller — better to fail a fraction of requests quickly than to let latency collapse for all of them.
  • Combine with a circuit breaker and backoff so that rejected work isn't immediately retried into the same overloaded consumer (which just re-creates the overload).
  • Don't rely on backpressure alone if the producer can't be slowed (e.g. an external firehose you don't control) — there you need buffering-with-bounds plus load shedding, because there's no upstream to push back on.

Common pitfall

Defaulting to unbounded queues and buffers because they "never reject anything." This is the most common backpressure failure precisely because it looks correct in testing — under normal load the buffer stays small and everything works, so the missing bound is invisible. Then a traffic spike or a consumer slowdown arrives, the buffer grows without limit, and the service dies from memory exhaustion — often taking down neighbors as the failure cascades. The fix is to bound every queue and decide, deliberately, what happens when the bound is hit (block the producer, or shed load), rather than letting "infinite buffer" be the accidental default. The second pitfall is backpressure that doesn't propagate: throttling one stage but letting the stage before it keep buffering unboundedly just moves the OOM one hop upstream.

Principal Engineer Lens

Backpressure is the discipline of designing for the case where demand exceeds capacity — which, at scale, is not an edge case but a certainty during every spike, deploy, or dependency slowdown. The Principal-level instinct is to look at any queue or buffer in a design and immediately ask "what's the bound, and what happens when it's hit?" — because an unbounded buffer is a hidden single point of failure that testing won't reveal. It reframes resilience from "handle more load" to "degrade predictably when load exceeds capacity," which is the more honest and more defensible engineering posture. In trading and payments systems this is directly load-bearing: an order-ingestion path that buffers unboundedly under a market-open surge will fail catastrophically at the worst possible moment, whereas one that sheds or throttles predictably stays alive and recoverable — and "what happens when we exceed capacity" is exactly the question a risk review will ask.

Reel Script

Setup: A fast producer feeds a slower consumer — say, a service ingesting a burst of events into a database that can't write them as fast as they arrive. The difference has to go somewhere. By default it piles up in a queue, and that queue grows, and grows, until the service runs out of memory and crashes.

Concept walkthrough: Backpressure is the overwhelmed consumer telling the producer "slow down — I can only take N more." Instead of a buffer silently swelling until it dies, capacity becomes an explicit, propagating signal. It shows up as bounded queues that block the producer when full, as pull-based streams where the consumer requests exactly what it can handle, as credit/windowing schemes like TCP's sliding window, or as fast rejection — shedding load with a 429 when you truly can't keep up.

Real example tie-in: Contrast two ingestion services under a spike. The unbounded one absorbs everything — looks great — then OOMs and takes its neighbors down as the failure cascades. The bounded one blocks or sheds early: some producers wait, or some requests get a fast 429, but the service stays alive and recovers when the spike passes. Same load, opposite outcome.

Tradeoffs & alternatives: Backpressure doesn't delete the excess load — it moves the pain from "consumer crashes" to "producer throttled" or "some requests rejected," which are recoverable where a crash isn't. If you can't slow the producer (an external firehose), you need bounded buffering plus load shedding instead. And pair rejection with backoff, or retries just re-create the overload.

Principal Engineer takeaway: Look at every queue and buffer and ask "what's the bound, and what happens when it's hit?" An unbounded buffer is a hidden crash waiting for the right spike. Resilience isn't "handle infinite load" — it's "degrade predictably when load exceeds capacity."

Sources:

Hermes Wiki