MVP Factory
ai startup development

gRPC bidirectional streaming: mobile backpressure done right

KW
Krystian Wiewiór · · 5 min read

Meta: Implement gRPC bidirectional streaming with proper backpressure on Android using Kotlin coroutines, OkHttp, and Netty flow control for 10k concurrent streams.


TL;DR

Bidirectional gRPC streaming falls apart under load when teams ignore HTTP/2 flow control windows, skip cancellation propagation, and leave Netty at defaults. The OkHttp/gRPC-Kotlin coroutine bridge gives you the right primitives — but only if you wire them correctly. Most teams don’t, and it becomes a production incident.


Why mobile streaming is a different problem

At 1,000 concurrent streams your app feels fine. At 10,000 it OOM-kills — and the default Netty config is usually why. Bidirectional streaming removes the natural backpressure that REST and unary gRPC provide: with those patterns, the client controls when it makes the next request. Bidirectional streaming removes that constraint. Your server can now push frames faster than the Android client can process them — and when it does, you get buffer bloat, OOM kills, and head-of-line (HOL) stalls where one slow stream degrades all streams on the same HTTP/2 connection.

The math is brutal. At 10,000 concurrent streams on a single Netty server, the default INITIAL_WINDOW_SIZE of 65,535 bytes per stream means roughly 625 MB of flow control buffer space (10,000 × 65,535 ÷ 1,048,576) before sending a single byte of application payload. This is not a theoretical concern — it is a runtime memory cliff.


HTTP/2 flow control: the layer most teams skip

HTTP/2 defines two levels of flow control: connection-level and stream-level. Both must be tuned independently.

ParameterDefaultRecommended for mobileWhy
Stream initial window65,535 bytes256 KB–1 MBLarger window raises throughput but increases peak memory per stream; 512 KB is a safe starting point for most mobile payloads
Connection window65,535 bytes4–8 MBMust exceed the per-stream window so the connection itself is never the bottleneck when multiple streams share it
Max concurrent streamsUnlimited100–250 per connectionCaps memory exposure and keeps latency predictable; tune upward only after profiling
Max frame size16,384 bytes16–32 KBSmaller frames let the scheduler interleave streams fairly; larger frames marginally reduce framing overhead but worsen HOL stalls

On the Netty server side, the critical parameters go through NettyServerBuilder:

NettyServerBuilder.forPort(50051)
    .flowControlWindow(1 * 1024 * 1024) // 1 MB per stream
    .maxConcurrentCallsPerConnection(200)
    .maxInboundMessageSize(4 * 1024 * 1024)
    .addService(YourStreamingService())
    .build()

Raising the stream window above 1 MB typically yields diminishing returns on mobile links and increases memory pressure. Keep the connection-level window larger than the per-stream window — otherwise the connection becomes the bottleneck the moment multiple streams are active simultaneously.


The OkHttp/gRPC-Kotlin coroutine bridge

On Android, grpc-kotlin exposes bidirectional streaming as Flow<Request> in, Flow<Response> out. Elegant. But the bridge between OkHttp’s thread-pool model and Kotlin’s structured concurrency requires explicit attention, and “explicit attention” means you actually have to wire it.

// Client-side channel with explicit flow control window
val channel = OkHttpChannelBuilder
    .forAddress("api.example.com", 443)
    .flowControlWindow(512 * 1024) // 512 KB per stream
    .maxInboundMessageSize(2 * 1024 * 1024)
    .build()

val stub = YourServiceGrpcKt.YourServiceCoroutineStub(channel)

// Bidirectional streaming with structured cancellation
val requestFlow: Flow<Request> = flow {
    emit(buildInitialRequest())
    // emit subsequent messages driven by UI events
}

coroutineScope {
    stub.bidirectionalStream(requestFlow).collect { response ->
        processResponse(response)
    }
}

When the coroutineScope is cancelled — Activity destruction, navigation, explicit user action — cancellation propagates through the Flow collector, signals the gRPC stub, and sends an HTTP/2 RST_STREAM frame to the server. Clean path. What breaks it is launching collection in GlobalScope or viewModelScope without tying cancellation to the stream lifecycle.


Cancellation propagation: the silent correctness bug

In my experience building production systems, the most common failure mode is not a crash — it is a zombie stream. The Android client navigates away, the coroutine is cancelled, but the server keeps pushing frames because the RST_STREAM was never sent. This happens when:

  • The stub call is wrapped in a try/catch that swallows CancellationException
  • The stream is collected inside a launch that outlives the UI lifecycle
  • A blocking call on the gRPC thread pool prevents the cancellation signal from being dispatched

Treat CancellationException as a first-class signal, not an error. Scope collection to the narrowest lifecycle that owns the stream.


Eliminating HOL stalls at scale

HTTP/2 mitigates application-layer head-of-line stalls across streams, but TCP-layer HOL remains — only HTTP/3 (QUIC) eliminates it fully. Within HTTP/2’s scope, tuning maxFrameSize on Netty to 16–32 KB keeps individual frames small enough that the scheduler can interleave streams fairly. Combined with a reasonable stream window size, this keeps P99 latency stable as concurrent stream count grows.

NettyServerBuilder.forPort(50051)
    .withChildOption(ChannelOption.SO_RCVBUF, 2 * 1024 * 1024)
    .withChildOption(ChannelOption.SO_SNDBUF, 2 * 1024 * 1024)
    .flowControlWindow(1 * 1024 * 1024)
    .maxConcurrentCallsPerConnection(200)
    // ... .build()

Before you ship

  1. Set explicit flow control windows on both client and server — don’t ship with Netty or OkHttp defaults. Start with a 512 KB–1 MB stream window and adjust based on profiled throughput, not intuition.

  2. Tie stream lifecycle to the narrowest possible coroutine scope. Use viewModelScope or a custom CoroutineScope tied to the UI component. Let structured concurrency handle RST_STREAM propagation automatically — never collect a bidirectional stream in GlobalScope.

  3. Instrument your connection-level window utilization before scaling. HOL stalls at 10k streams are a configuration problem, not a capacity problem. Profile stream frame interleaving in staging before you hit it in production.


Tags: grpc kotlin android mobile backend


Share: Twitter LinkedIn