---
title: "gRPC Bidirectional Streaming on Android: Backpressure Done Right at 10k Streams"
published: true
description: "Flow control windows, cancellation propagation, and the OkHttp/gRPC-Kotlin coroutine bridge that keeps your streaming endpoint alive under real load — including Netty tuning for 10k concurrent streams."
tags: kotlin, android, mobile, api
canonical_url: https://blog.mvpfactory.co/grpc-bidirectional-streaming-android-backpressure
---
## What We Are Building
By the end of this tutorial you will have a production-ready gRPC bidirectional streaming setup on Android. Specifically: a correctly wired OkHttp/gRPC-Kotlin coroutine bridge with explicit HTTP/2 flow control windows, structured cancellation that sends `RST_STREAM` automatically, and a Netty server tuned to handle 10,000 concurrent streams without an OOM kill.
This is not a hello-world streaming example. This is the setup I use in every project where streaming matters.
## Prerequisites
- Android project targeting API 24+
- Kotlin coroutines (`kotlinx-coroutines-android`)
- `grpc-kotlin-stub` and `grpc-okhttp` on the client
- `grpc-netty` on the server side
- Basic familiarity with gRPC service definitions and protobuf
---
## Why This Is Harder Than It Looks
At 1,000 concurrent streams your app feels fine. At 10,000 it OOM-kills — and the default Netty configuration 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 entirely. 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 stalls where one slow stream degrades every stream on the same HTTP/2 connection.
Here is the math that makes this a real problem. 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** before sending a single byte of application payload:
10,000 streams × 65,535 bytes ÷ 1,048,576 = ~625 MB
That is not a theoretical concern. It is a runtime memory cliff.
---
## Step 1: Tune Your HTTP/2 Flow Control Windows
HTTP/2 defines two levels of flow control: connection-level and stream-level. Both must be tuned independently. Here is the pattern I follow:
| Parameter | Default | Recommended for Mobile | Why |
|---|---|---|---|
| Stream initial window | 65,535 bytes | 256 KB–1 MB | 512 KB is a safe starting point for most mobile payloads |
| Connection window | 65,535 bytes | 4–8 MB | Must exceed per-stream window so the connection is never the bottleneck |
| Max concurrent streams | Unlimited | 100–250 per connection | Caps memory exposure and keeps latency predictable |
| Max frame size | 16,384 bytes | 16–32 KB | Smaller frames let the scheduler interleave streams fairly |
On the Netty server, wire these through `NettyServerBuilder`:
kotlin
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.
---
## Step 2: Wire 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.
Here is the minimal setup to get this working correctly:
kotlin
// 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 = 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 automatically. Clean path. What breaks it is launching collection in `GlobalScope` or a `viewModelScope` that is not tied to the stream lifecycle.
---
## Step 3: Eliminate 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, tuning `maxFrameSize` to 16–32 KB keeps individual frames small enough that the scheduler can interleave streams fairly.
Add the socket buffer options alongside your flow control config:
kotlin
NettyServerBuilder.forPort(50051)
.withChildOption(ChannelOption.SO_RCVBUF, 2 * 1024 * 1024)
.withChildOption(ChannelOption.SO_SNDBUF, 2 * 1024 * 1024)
.flowControlWindow(1 * 1024 * 1024)
.maxConcurrentCallsPerConnection(200)
// ... .build()
Combined with a reasonable stream window size, this keeps P99 latency stable as concurrent stream count grows.
---
## Gotchas
**Zombie streams are more common than crashes.** The most common failure mode is not an OOM — it is a zombie stream. The Android client navigates away, the coroutine is cancelled, but the server keeps pushing frames because `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, or 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.
**Never collect a bidirectional stream in `GlobalScope`.** Scope collection to the narrowest lifecycle that owns the stream — `viewModelScope` tied to the owning ViewModel, or a custom `CoroutineScope` tied directly to the UI component.
**The docs do not make the two-level window distinction obvious.** Most tutorials tune the stream window and ignore the connection window. If your connection window is smaller than your stream window, the connection becomes the bottleneck the moment two streams are active simultaneously. Always set the connection window to at least 4–8 MB.
**Don't skip profiling before you scale.** 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.
---
## Before You Ship
1. Set explicit flow control windows on both client and server. Start with 512 KB–1 MB per stream and adjust based on profiled throughput, not intuition.
2. Tie stream lifecycle to the narrowest possible coroutine scope and let structured concurrency handle `RST_STREAM` propagation automatically.
3. Instrument your connection-level window utilization before scaling. The numbers above are starting points — your payload profile will determine the right values.
**Relevant docs:**
- [gRPC-Kotlin GitHub](https://github.com/grpc/grpc-kotlin)
- [Netty NettyServerBuilder API](https://grpc.github.io/grpc-java/javadoc/io/grpc/netty/NettyServerBuilder.html)
- [HTTP/2 Flow Control — RFC 9113](https://www.rfc-editor.org/rfc/rfc9113#name-flow-control)
Get these three things right and bidirectional streaming at 10k concurrent streams becomes a configuration exercise, not a production incident.
Top comments (0)