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.
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.
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.
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.
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.