A Fast Producer, A Slow Consumer The buffer in the middle isn't a fix. It's where your latency goes to hide. Producer 10k/s queue (growing) overflow → latency, then OOM 2k/s Consumer 2k/s max

Every engineer meets the same moment. A service is falling over under load — queues climbing, latency spiking, memory creeping up. The first instinct is almost universal: make the buffer bigger. Bump the queue size, raise the channel capacity, give the consumer a longer runway. It feels like relief, and for about ten minutes it is. Then the same thing happens again, slower and worse. This time the whole process dies from an out-of-memory kill instead of a graceful slowdown. The bigger buffer didn’t fix anything. It hid the problem, converted it into latency, and postponed the crash.

Here’s the thread the whole post hangs on. When a producer is faster than a consumer, no amount of buffer will save you — the buffer only changes how you fail. Overload is not a storage problem you can absorb. It’s a rate mismatch you have to resolve. The mechanism that resolves it is backpressure: a slow consumer reaches back up the pipeline and says “slow down”. Get that right and overload becomes a controlled slowdown. Get it wrong — or reach for a bigger buffer instead — and overload becomes an outage.

Why a bigger buffer is a trap, in one equation

Reduce a system to its essence. A producer puts work into a queue at some arrival rate λ. A consumer removes work at some service rate μ. There are only two cases, and the boring one is fine:

  • λ ≤ μ — the consumer keeps up. The queue stays short, drains between bursts, and everyone’s happy. A buffer here is genuinely useful: it smooths out momentary spikes.
  • λ > μ — the producer is winning. The queue grows by (λ − μ) items every second, forever. This is the case that kills you, and it’s the case a bigger buffer is powerless against.

Little’s Law explains why a buffer can’t save the second case. An item’s average time in the system is the number of items waiting divided by the service rate, W = L / μ. If the queue length L keeps climbing while μ stays fixed, then W — your latency — climbs with it. A deeper queue does not make work finish faster. It only means each item waits behind more items before its turn. You didn’t add capacity. You added delay.

The buffer is where latency hides. A full 10,000-item queue drained at 2,000/sec means every new arrival waits 5 seconds before the consumer even looks at it. Double the buffer and you’ve doubled the wait to 10 seconds. The requests aren’t failing — they’re sitting in line long enough that the client already timed out and retried, which adds more load. This is “bufferbloat”: the buffer you added to help now makes latency worse.

And it has a hard ceiling. A queue lives in memory, and memory is finite. An unbounded queue under sustained λ > μ has exactly one destination. It grows until the process exhausts the heap, and the OOM killer ends it. Or garbage collection thrashes so hard that the service dies long before that. So the “just make it bigger” strategy doesn’t avoid the failure. It picks the worst possible failure: instead of a graceful slowdown, you get a sudden, total, data-losing crash, at the least predictable moment.

Same overload, two very different outcomes offered load → latency OOM / crash 💥 bigger buffer: latency → ∞ backpressure: latency plateaus, stays up

What backpressure actually is

Backpressure is a signal that flows backwards — opposite to the data. Data moves producer → consumer. The “I’m full, slow down” signal moves consumer → producer. That’s the whole idea, and it’s why the plumbing metaphor is apt. When the drain can’t keep up, pressure builds back up the pipe. The pressure then forces the tap itself to slow. The system stops pretending it can accept infinite work and starts telling the truth about its capacity.

A buffer answers “where do I put the work I can’t handle yet?” Backpressure answers a better question: “how do I stop the producer handing me work I can’t handle?”

Concretely, an overwhelmed consumer has only four honest responses — and every real system is some combination of these four. There is no fifth option. “Buffer forever” is just refusing to choose.

The four things you can do with work you can’t handle

1. Block the producer (make it wait)

The simplest form: put a bounded queue between producer and consumer. When it is full, the producer’s put() simply blocks until space becomes available. The producer is now physically unable to outrun the consumer — the queue’s capacity is a hard leash. This is what a BlockingQueue with a fixed size does, and it’s the cleanest backpressure there is. There is no signalling protocol. The queue simply makes the fast side wait on the slow side.

// Bounded queue = backpressure for free.
// When full, the producer blocks instead of piling up memory.
const queue = new BoundedQueue({ capacity: 1000 });

async function produce(item) {
  await queue.put(item);   // ← BLOCKS when full. The producer slows itself.
}

async function consume() {
  while (true) {
    const item = await queue.take();
    await process(item);   // slow. While we're here, the queue fills,
                           // and produce() is now waiting on us.
  }
}

The catch: blocking only works if the producer can slow down. That’s true for an internal pipeline stage. It’s false for a firehose you don’t control. An incoming flood of user requests, IoT sensors, or a market-data feed won’t “wait” just because you blocked. For those, you need option 2.

2. Drop it (load shedding)

If you can’t slow the source and can’t store the work, the only remaining honest option is to throw some of it away. You throw it away deliberately, while you still can, instead of letting the OOM killer throw everything away later. This is load shedding. Done well, it’s a feature, not a failure. Reject with a fast 429 Too Many Requests, drop the oldest items (they’re probably stale anyway), or sample. A load balancer returning 503s under overload is choosing to serve 80% of traffic well rather than 100% of it terribly.

Shedding early beats collapsing late. The counterintuitive truth of overload: a system that refuses 20% of requests instantly can keep the other 80% fast and healthy. A system that accepts 100% and queues them all serves everyone a timeout and then dies. Saying “no” quickly is often the most available thing a service can do.

3. Buffer it — but bounded, as a shock absorber

Buffers aren’t evil. Unbounded buffers are. A small, fixed-size buffer is exactly the right tool for smoothing brief bursts where λ momentarily exceeds μ but averages out below it. The rule is simple: a buffer is a shock absorber for transient spikes, never a solution for sustained overload. So you always pair a bounded buffer with a policy for what happens when it fills — block (option 1) or drop (option 2). The buffer buys time. It does not buy capacity.

4. Control the rate (credits and windows)

The most sophisticated option flips the whole relationship. Instead of the producer pushing and hoping, the consumer pulls. It tells the producer exactly how much it’s ready for. “Send me 10 items.” The producer sends at most 10 and then stops until the consumer grants more. Demand flows upstream. Data flows downstream only as far as there’s demand for it. This credit-based, pull model is the backbone of the flow-control systems that engineers trust most. It starts with the one running underneath this very page.

The greatest backpressure system ever shipped: TCP

You’ve been relying on textbook backpressure this whole time without noticing. TCP flow control is the canonical implementation, and it’s option 4 done to perfection. Every TCP receiver advertises a receive window — “I have room for N more bytes right now” — in every acknowledgement it sends back. TCP forbids the sender from holding more than that many unacknowledged bytes in flight. If the receiving application is slow to read, its buffer fills and the advertised window shrinks toward zero. A small window throttles the sender. A zero window stops the sender completely.

TCP: the receive window IS backpressure Sender Receiver buffer filling → data (up to window bytes) ACK + "window = 8k … 4k … 0" App reads slowly → buffer fills → window shrinks → sender slows. No drops, no OOM.

Notice what TCP doesn’t do. It doesn’t buffer the whole stream in the sender and hope the receiver catches up, and it doesn’t drop your data on the floor. It propagates the receiver’s real capacity back to the sender and makes the sender match it. That’s backpressure as a first-class protocol feature. A slow disk on the receiving end quietly slows the sender instead of crashing anyone.

The modern version: Reactive Streams and request(n)

The same credit idea got standardized for application code as Reactive Streams (the basis of Project Reactor, RxJava, Akka Streams, and java.util.concurrent.Flow). A subscriber doesn’t just receive an unbounded torrent of events. It calls subscription.request(n) to signal “I can handle n more.” The contract forbids the publisher from sending more than the outstanding demand. It’s TCP’s window, lifted into your object graph. A slow database sink at the end of a stream automatically slows the HTTP source at the front, with no manual queue tuning.

Node.js streams do this too. When you readable.pipe(writable), Node watches the writable’s highWaterMark. If write() returns false (buffer full), pipe() automatically pauses the readable until a 'drain' event says there’s room again. That’s backpressure built into the standard library. If you bypass it and ignore the return value of write() in a hot loop, you OOM a Node process. That is one of the most common mistakes people make.

The rule everyone gets wrong: it has to go all the way back

Here’s the mistake that turns backpressure into a false sense of security. You add a bounded queue between stage B and stage C and feel good about it. Then you leave an unbounded queue between stage A and stage B. Now when C is slow, B backpressures correctly… and all the work simply accumulates in the A→B queue instead. You didn’t stop the flood. You only moved where it pools. The process still OOMs, just in a different buffer.

Backpressure must be end-to-end. A pipeline is only as backpressured as its weakest link. The “slow down” signal has to propagate unbroken from the true bottleneck all the way to the original source of the work. One unbounded buffer anywhere in the chain silently absorbs all the pressure and defeats every bounded stage downstream of it. Audit your pipeline for the buffer nobody bounded — it’s usually the one that kills you.

And this is where the two families split. When your source is something you can slow, backpressure propagates cleanly. Those sources include an internal producer, a Kafka consumer reading at its own pace, and a TCP sender. Nobody loses data. When your source is something you can’t slow — live user traffic, sensors, an external feed — backpressure has nowhere left to push. The end of the line must then shed load. That’s not a failure of backpressure. It’s the honest boundary of it. Backpressure handles the work you can defer. Load shedding handles the work you can’t.

Backpressure (slow the source)
  • No data lost — you defer work instead of dropping it.
  • Latency plateaus instead of exploding.
  • Works when the producer can wait (internal stages, TCP, Kafka).
  • Memory stays bounded by construction.
Load shedding (drop the excess)
  • You drop data — but deliberately, not catastrophically.
  • The only option when you can’t slow the source.
  • Keeps accepted requests fast and the service alive.
  • Needs a good policy: reject, drop-oldest, or sample.

Where you actually meet this

You rarely build a flow-control protocol from scratch — you inherit one and, more often, you get burned by turning it off or ignoring it:

  • Kafka. Consumers pull at their own rate — that is backpressure. The queue is the durable log, so a slow consumer just shows up as growing consumer lag rather than a crash. The failure mode isn’t OOM, it’s falling behind — which is a far kinder thing to monitor and alert on.
  • gRPC / HTTP 2. Stream-level flow-control windows (inherited straight from the TCP idea) throttle a fast sender when a stream’s receiver is slow, independently per stream.
  • Node.js streams. highWaterMark + write() returning false + 'drain'. Respect the return value and you’re safe. Ignore it and you OOM.
  • Thread pools. A pool with a bounded work queue plus a rejection policy (e.g. CallerRunsPolicy, which makes the submitting thread run the task itself) is backpressure: submitters get slowed or rejected instead of the queue growing forever. An unbounded pool queue is the classic hidden OOM.
  • Database connection pools. A fixed pool size is backpressure against your database — callers wait for a connection instead of opening ten thousand and melting the DB. (The whole connection-pool sizing story is really a backpressure story.)
  • Reactive stacks (Akka Streams, Reactor, RSocket). request(n) demand signalling end to end, so a slow sink automatically throttles a fast source.

The takeaway

“Make the buffer bigger” is the most natural wrong answer in systems engineering. It feels like adding capacity. It actually adds delay, and it swaps a graceful slowdown for a sudden crash. The reframe that fixes it is small but total:

  1. Overload is a rate problem, not a storage problem. When λ > μ, no buffer is big enough — it only decides whether you fail as rising latency or as a hard OOM. The real fix is to make the fast side match the slow side: backpressure, a “slow down” signal that flows upstream against the data.
  2. Every consumer has exactly four honest options — block, drop, bound-and-buffer, or rate-control — and the signal must reach all the way to the source. Where the source can wait, backpressure defers the work losslessly. Where it can’t, you must shed load deliberately. One unbounded buffer anywhere in the chain defeats the whole scheme.

So the next time a service is buckling and your hand drifts toward the queue-size config, stop. Ask the two questions that separate a resilient system from a time-bomb. First: is this a transient spike a small buffer can absorb, or sustained overload no buffer can? Second: when this queue fills, does the pressure travel back to something that can actually slow down? Or does it just accumulate somewhere I forgot to bound? The systems that survive overload aren’t the ones with the biggest buffers. They’re the ones that learned to say “slow down” — and, when they must, “no.”

Further reading. John Nagle, RFC 896 / RFC 970 on congestion and “bufferbloat”-adjacent thinking. The Reactive Streams specification for request(n) demand. The Node.js streams backpressure guide. Ferd Hébert, “Handling Overload” and “Queues Don’t Fix Overload”. Fred Hebert & the Erlang community on load regulation. Little’s Law (L = λW) is the one piece of queueing theory every backend engineer should keep in their pocket. This closes a loop with connection-pool sizing and reactive scaling — both are backpressure wearing a different hat.