← back

gRPC flow control: notes from a debugging session

March 17, 2026

Spent a Saturday afternoon chasing a server-streaming RPC that throughput-capped at exactly 65535 bytes per second. The number should have been a giveaway — that's the default HTTP/2 initial window — but it took me longer than I'd like to admit.

The two windows

HTTP/2 has two flow-control windows: per-stream and per-connection. Both start at 65535 bytes by default. The client advertises a larger window in a WINDOW_UPDATE frame after the connection is established. If the client never does that, you're stuck at 64KiB worth of in-flight data.

In Go's gRPC the relevant dial options are:

grpc.WithInitialWindowSize(1 << 20),         // per-stream
grpc.WithInitialConnWindowSize(1 << 20),     // per-connection

Bump both to 1 MiB and the cap goes away for any reasonable RTT.

Why "exactly 64 KiB/s"?

RTT in my case was ~80ms, so I was averaging about 12 round trips per second, each carrying ~5460 bytes... actually no, the math works out to almost exactly the BDP at the cap. The point is: if you see a throughput plateau that looks like a round number — 64K, 256K, 1M — suspect a window somewhere.

Logging

gRPC's GRPC_GO_LOG_VERBOSITY_LEVEL=2 GRPC_GO_LOG_SEVERITY_LEVEL=info will print frame-level events including window updates. It's noisy but invaluable when you're stuck.

BDP estimation

Both grpc-go and grpc-java have a "BDP estimator" that grows the window automatically when it detects throughput is being clipped by it. In theory you shouldn't need to set the initial window manually. In practice, the estimator is conservative and can take many seconds to kick in. For a server-streaming workload that needs to be fast from byte zero, set it explicitly.