Flow control and performance tuning

gRPC · Expert · 15 min

Throughput problems in gRPC usually come from windows, limits and connection usage, not CPU.

HTTP/2 flow control stops a fast sender from overwhelming a slow receiver: each stream and each connection has a window of bytes the peer may send before receiving a WINDOW_UPDATE. The C++, Go and Java implementations use BDP probing (bandwidth-delay product estimation with PINGs) to grow windows automatically on high-latency, high-bandwidth links. When a streaming consumer stops reading, the window fills and the sender's write() reports backpressure: this is how gRPC streaming is flow-controlled end to end.

Other levers, in rough order of usefulness:

  • Reuse channels. Create channels once; each new one costs DNS, TCP and TLS handshakes.
  • Concurrent stream limits. Servers advertise MAX_CONCURRENT_STREAMS (often 100). Under heavy load a single connection queues calls; use several channels (a channel pool) or client-side balancing across many backends.
  • Message size. The default 4 MB receive limit (grpc.max_receive_message_length) exists for a reason; prefer streaming chunks to raising it.
  • Compression (gzip) trades CPU for bandwidth; worthwhile on large, repetitive payloads over slow links, wasteful on small messages in a data centre.
  • Serialization cost: in Node, dynamic proto-loader objects are convenient but slower than generated code; large bytes fields avoid copying when kept as Buffers.

Measure with ghz or the gRPC benchmark tools, watch p99 latency rather than averages, and change one setting at a time.

Example

import * as grpc from "@grpc/grpc-js";

// Channel options for a high-throughput internal client
const options = {
  "grpc.max_receive_message_length": 8 * 1024 * 1024, // 8 MB instead of 4 MB
  "grpc.max_send_message_length": 8 * 1024 * 1024,
  "grpc.default_compression_algorithm": 2,             // 2 = gzip
  "grpc.keepalive_time_ms": 60_000,
};

// Server side: cap concurrent streams per connection
const server = new grpc.Server({ "grpc.max_concurrent_streams": 200 });
console.log(Object.keys(options).length, typeof server.bindAsync); // 4 function

// Load test from a shell (ghz):
// ghz --insecure --proto hello.proto --call hello.v1.Greeter.SayHello \
//     -d '{"name":"Ann"}' -c 50 -n 20000 localhost:50051

Remember

  • HTTP/2 windows give end-to-end backpressure; BDP probing grows them
  • MAX_CONCURRENT_STREAMS can queue calls on one connection
  • Stream large payloads instead of raising message limits
  • Compress only when bandwidth, not CPU, is the bottleneck

Watch out for

  • Raising max message size to hundreds of MB instead of streaming, causing memory spikes.

Check yourself

  1. A busy client sends thousands of concurrent calls over one channel and sees queuing. A likely cause?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: grpc.io/docs/guides/performance/

Builds on

Unlocks

Nothing builds on this yet.