Flow Control & Backpressure
The problem streams create
Section titled “The problem streams create”A stream lets a producer send as fast as it can. But what if the consumer reads slowly? Without a brake, the fast side would pile up messages in memory faster than the slow side drains them — until something runs out of memory and falls over.
The brake is flow control, and the pressure it pushes back on the producer is backpressure.
HTTP/2 flow control, for free
Section titled “HTTP/2 flow control, for free”Because gRPC rides on HTTP/2, it inherits HTTP/2’s flow control automatically. Each side advertises a window — how many bytes it is currently willing to receive. As data arrives and is consumed, the receiver replenishes the window; if the receiver stops consuming, the window drains to zero and the sender must stop.
flowchart LR prod["fast producer"] -->|writes| win["flow-control window"] win -->|reads| cons["slow consumer"] win -. "window empty → Send blocks" .-> prod cons -. "consume → replenish window" .-> win
The effect: when you call Send and the window is full, Send blocks (or, in callback APIs, signals “not ready”/drain) until the consumer catches up. The transport is telling the producer, “slow down.” That is backpressure working correctly — and it happens without you writing any of it.
Where it goes wrong: defeating your own backpressure
Section titled “Where it goes wrong: defeating your own backpressure”The danger is not HTTP/2 — it handles flow control well. The danger is code that buffers around the backpressure and reintroduces the unbounded-memory problem:
- Reading everything into a slice/list before processing. If your receive loop appends every message to an in-memory list, you’ve turned a bounded stream into unbounded memory. Process each message as it arrives.
- A send loop that never blocks. In callback-based runtimes (Node), ignoring the
write()return value /drainevent lets you queue unbounded data in the library’s internal buffer. Respect the “please wait” signal. - Not reading while writing. On a bidi stream, if you only send and never receive, your peer’s flow-control acknowledgements pile up and both sides can deadlock. Always run the receive loop concurrently.
Design guidance
Section titled “Design guidance”- Process, don’t accumulate. Handle each streamed message and let it go; don’t collect them all first.
- Let backpressure reach the source. If you’re relaying a stream (reading from a DB, sending to a client), let a blocked
Sendpause your reads from the DB too. Then the slow consumer naturally throttles the fast source. - Bound everything you can’t stream. Cap message counts and sizes so a misbehaving peer can’t force unbounded work.
- Know when NOT to stream. If a slow consumer would force the server to hold state or memory for a long time, a stream can be a liability. Sometimes many small unary calls (each independently retryable and rebalanceable) are the safer design than one long stream — the same trade-off from this module’s intro.