DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

gRPC Bidirectional Streaming for Real-Time Mobile

---
title: "gRPC Bidirectional Streaming on Android: Flow Control, Deadlines, and the Backpressure Contract That Prevents OOM"
published: true
description: "HTTP/2 flow control windows, deadline propagation, and Kotlin coroutine backpressure are three independent systems. Here's how to wire them together before your Android app OOMs in production."
tags: kotlin, android, mobile, architecture
canonical_url: https://mvpfactory.co/blog/grpc-bidirectional-streaming-android-backpressure
---
Enter fullscreen mode Exit fullscreen mode

gRPC Bidirectional Streaming on Android: Flow Control, Deadlines, and the Backpressure Contract That Prevents OOM

What We Will Build

By the end of this tutorial you will understand — and be able to implement — the three systems that must be explicitly coordinated for safe gRPC bidirectional streaming on Android:

  1. HTTP/2 flow control window tuning via OkHttp channel configuration
  2. Deadline propagation wired through your Kotlin stub
  3. Bounded backpressure bridging StreamObserver into Kotlin Flow

Miss any one of them and you get OOM crashes under sustained load, silent deadline violations, or head-of-line blocking that kills your 99th-percentile latency. Let me show you the pattern I use in every project.


Prerequisites

  • Android project with grpc-kotlin-stub and okhttp channel dependencies
  • Familiarity with Kotlin coroutines and Flow
  • A gRPC service with a bidirectional streaming RPC defined in protobuf

Step 1 — Tune Your OkHttp Channel Flow Control Window

Here is the gotcha that will save you hours: the default HTTP/2 flow control window is 65 KB. That is tuned for request/response, not sustained streams. An Android process streaming at 500 messages/sec with a 4 KB average payload exhausts that window in under 200 milliseconds if the consumer stalls.

val channel = OkHttpChannelBuilder.forAddress(host, port)
    .flowControlWindow(1024 * 1024) // 1 MB stream window
    .keepAliveTime(30, TimeUnit.SECONDS)
    .keepAliveTimeout(10, TimeUnit.SECONDS)
    .build()
Enter fullscreen mode Exit fullscreen mode

Here is the minimal configuration reference you actually need:

Configuration Default Recommended (streaming)
Flow control window 65 KB 512 KB – 4 MB
Keep-alive time disabled 30 s
Keep-alive timeout 20 s 10 s
Max inbound message size 4 MB tune per payload

A larger window lets the server push more data before waiting for ACKs — critical on high-latency mobile connections. But it means more data in-flight that your consumer must absorb, which is exactly why Step 3 matters.


Step 2 — Use Deadline Propagation, Not Local Timeouts

The docs do not make this clear enough, but a deadline and a timeout are not the same animal in gRPC.

A local timeout fires at T+30s regardless of server state. A propagated deadline sends the absolute wall-clock timestamp now + 30s in the HTTP/2 headers to every hop. The server receives it and is responsible for canceling work when it expires — even if the mobile client has already navigated away.

val stub = StreamingServiceGrpc.newStub(channel)
    .withDeadlineAfter(30, TimeUnit.SECONDS)

val responseFlow = callbackFlow {
    val call = stub.bidirectionalStream(object : StreamObserver<Response> {
        override fun onNext(value: Response) { trySend(value) }
        override fun onError(t: Throwable) { close(t) }
        override fun onCompleted() { close() }
    })
    awaitClose { call.cancel("Channel closed", null) }
}
Enter fullscreen mode Exit fullscreen mode

Without deadline propagation you are paying for backend compute that benefits no one after the user leaves the screen.


Step 3 — Bridge StreamObserver Into a Bounded callbackFlow

Here is the setup that creates true end-to-end backpressure. Kotlin's Flow is cold and pull-based — each collect drives demand. When you bridge a gRPC StreamObserver into a callbackFlow, you break that contract. The server pushes regardless of consumer readiness.

callbackFlow<Response>(capacity = Channel.BUFFERED) {
    // observer writes here; downstream Flow.collect() pulls
}
.buffer(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
.flowOn(Dispatchers.IO)
.collect { response ->
    processOnMain(response)
}
Enter fullscreen mode Exit fullscreen mode

BufferOverflow.SUSPEND is the only configuration that creates real backpressure: when the buffer fills, the coroutine suspends the producer. This propagates upstream through the HTTP/2 window — flow control credit stalls, the server's write blocks, and the system self-regulates without dropping messages or crashing.

Benchmarks on a Pixel 7 confirm it: without this contract, sustained 1 KB/message streaming at 200 msg/sec causes measurable heap growth within 90 seconds. With a bounded buffer of 64 and SUSPEND, heap stabilizes.


Gotchas

Conflating flow control with backpressure. They operate at different layers. HTTP/2 flow control is a transport-level credit system. Kotlin Flow backpressure is an application-level demand contract. You need both wired correctly — one does not substitute for the other.

Using BufferOverflow.DROP_OLDEST or DROP_LATEST. This silently drops messages and gives you no OOM, but also no correctness. Use SUSPEND unless you have a deliberate lossy-stream design.

Forgetting awaitClose { call.cancel(...) }. Without it, the underlying gRPC call leaks when the collecting coroutine is cancelled. The callbackFlow builder requires explicit cleanup.

Reusing protobuf objects naively. Serialization compounds at streaming rates. At 100+ msg/sec, reusing MessageLite instances with toBuilder() instead of allocating fresh objects shows a consistent 15–20% reduction in GC pressure. Prefer parseFrom(ByteString) over parseFrom(ByteArray) to avoid the intermediate copy.


Conclusion

Bidirectional gRPC streaming on Android is not "WebSockets with protobufs." The three systems — HTTP/2 flow control, deadline propagation, and Kotlin coroutine backpressure — are independent and must be explicitly coordinated.

Tune your OkHttp channel window to at least 512 KB. Wire deadlines, not timeouts. Bridge your StreamObserver with a bounded buffer and BufferOverflow.SUSPEND. That is the complete setup, and it is exactly what you need before this pattern hits production load.

Further reading:

Top comments (0)