The Runtime Theory
API Design

gRPC Streaming and Backpressure: HTTP/2 Flow Control and Deadlines Explained

gRPC streaming and backpressure explained: HTTP/2 flow control, stream types, channel capacity, and why client deadlines are the only real backpressure.

The Runtime Theory Team3 min read#grpc#streaming#backpressure#http2#flow-control
On this page

gRPC is not "RPC over HTTP/2" the way you might hope — it is a completely different flow model. REST over HTTP/1.1 is one request, one response, one connection; gRPC multiplexes thousands of streams over one connection, each with its own flow-control window, and it gives the client a mechanism the server cannot ignore: the deadline. Understanding the machine means understanding three things: how HTTP/2 actually carries the streams, how flow control bounds them, and why deadlines are the only backpressure that matters end-to-end.

What the wire actually looks like

gRPC's transport is HTTP/2 frames over TCP. A client call creates a stream — identified by a 31-bit stream ID — and all frames for that call travel on that stream, interleaved with frames from thousands of other streams on the same connection. The protobuf messages are not sent whole; they're serialized, optionally compressed, sliced into DATA frames, and reassembled at the far end.

proto
syntax = "proto3";
 
service EventIngest {
  rpc Ingest(stream Event) returns (stream IngestStats);
  rpc Subscribe(SubscribeRequest) returns (stream Event);
  rpc GetEvent(EventID) returns (Event);           // unary: still a stream underneath
}

Even a "unary" call is a stream — one request frame, one response frame. The service definitions above map to three real stream shapes: client-streaming, server-streaming, and bidirectional. All of them share the same transport machinery: connection pooling, frame multiplexing, and per-stream flow control.

HTTP/2 flow control: windows, not water pistols

TCP has its own flow control, but gRPC must multiplex many logical streams over one TCP connection — so HTTP/2 adds a second layer: per-stream and per-connection flow-control windows. Each stream starts with a window (default 65,535 bytes of credit). A peer may only send as many bytes as it has credit for, and the receiver grants more credit by sending WINDOW_UPDATE frames as it consumes data.

text
Peer A (client)                    Peer B (server)
  |--- HEADERS (stream 3) --------------->|
  |--- DATA  (16KB, window 65KB) -------->|
  |--- DATA  (16KB, window 49KB) -------->|
  |--- DATA  (16KB, window 33KB) -------->|
  |<-- WINDOW_UPDATE (credit +32KB) ------|
  |--- DATA  (16KB, window 49KB) -------->|

Two practical consequences. First, a slow consumer on one stream stops that stream — its window fills and the producer stalls — while other streams on the connection keep flowing. That isolation is the entire point. Second, the receive buffer you configure in your gRPC client is a flow-control decision: grpc.MaxRecvMsgSize and the receive window directly bound how much in-flight data the peer may push at you.

Backpressure: where it lives, and where it doesn't

Backpressure in a streaming system is the chain of stall propagation: the slow consumer stops reading, its window fills, the producer stops sending, and the producer's own upstream stops producing. gRPC gives you the mechanism — flow control — but it only exists between the two processes at each hop. The chain only works if every hop participates:

  1. Client → server: the client's receive window stalls the server's sends.
  2. Server → upstream: the server must stop reading from its upstream when its outbound window fills, which requires its channel's concurrency to be bounded — maxInFlight, bounded queues, or blocking consumers.
  3. Application level: a goroutine reading from the stream in a tight loop into an unbounded slice breaks the chain — the read keeps consuming, the window keeps getting credit, and the server keeps producing, while the process's memory grows. The receive loop must apply its own backpressure by pausing reads when downstream consumers are slow.
go
// Naive: unbounded buffering defeats flow control entirely
for {
    msg, err := stream.Recv()
    queue.Push(msg)  // unbounded — window refills, server keeps sending
}

Deadlines: the only backpressure that survives the network

Flow control stalls a peer; it does not stop a hung peer. If a server hangs — deadlock, GC pause, upstream outage — the client waits forever unless it set a deadline. The deadline is propagated in the grpc-timeout header, and every intermediary that understands gRPC (proxies, other gRPC services) carries it along. A deadline is not a per-request timeout; it is a deadline across the whole call graph:

go
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
 
resp, err := client.GetEvent(ctx, &EventID{Id: "evt_1"})
if status.Code(err) == codes.DeadlineExceeded {
    // the client gave up — the server gets its own cancel signal via the context
}

When the deadline fires, the client sends RST_STREAM and the server's context is cancelled — the server's work-in-progress is aborted rather than allowed to finish into a closed stream. That cancellation is the other half of backpressure: it releases server resources instead of letting dead work pile up.

When streaming is the wrong tool

Streaming is the right tool when the data is genuinely incremental: log tails, CDC events, progress notifications, large payloads that should start rendering before they finish. It is the wrong tool when you need atomicity (streams have no transaction), when messages are large and few (unary plus payload chunking is simpler), or when your client is a browser — WebSocket/SSE-based wrappers exist but lose the flow-control semantics. Pick streaming for the incremental case; use unary for everything else.