MVP Factory
ai startup development

gRPC bidi streaming on KMP: Flow, proto3, and connection safety

KW
Krystian Wiewiór · · 5 min read

SEO Meta Description: End-to-end gRPC bidirectional streaming on Kotlin Multiplatform — from Ktor backend to Compose UI, covering Flow backpressure, proto3 codegen, and the connection pool failure that takes down your backend.


TL;DR

Bidirectional gRPC streaming on KMP is achievable today, but the path is full of traps. Proto3 codegen requires a platform-split strategy, Flow backpressure must be explicit at the transport boundary, and connection pool exhaustion during mass client reconnect is the failure mode most teams discover in production, not staging. What follows covers the full stack.


Why bidi streaming breaks differently on mobile

In my experience building production systems with gRPC, server streaming is forgiving. Bidirectional streaming is not. The moment both sides can emit concurrently, you inherit:

  1. Half-close semantics that differ between iOS and Android gRPC implementations
  2. Flow backpressure that lives in Kotlin coroutines but dies at the native transport boundary
  3. A connection lifecycle that the mobile OS will interrupt without warning

A single bidi stream holds a persistent HTTP/2 connection. At 100k mobile clients, even a 2% simultaneous reconnect event — triggered by a backend deploy or network partition — produces 2,000 concurrent connection establishment attempts. Without a ceiling on your server-side connection pool, this cascades into thread exhaustion within seconds.


Proto3 codegen for KMP: the platform split

The standard protoc + grpc-kotlin toolchain doesn’t produce KMP-compatible artifacts out of the box. The common mistake is generating JVM targets and trying to expect/actual around them. That doesn’t scale.

The correct approach uses protoc-gen-kotlin with a KMP module structure:

:proto-definitions      ← .proto files only
:shared:transport
  ├── commonMain        ← expect interfaces, Flow contracts
  ├── androidMain       ← grpc-kotlin stub wiring
  └── iosMain           ← grpc-swift bridge via cinterop

The commonMain layer defines only the data contract and stream interface:

// commonMain
interface ChatTransport {
    fun openStream(): Flow<ServerMessage>
    suspend fun send(msg: ClientMessage)
    suspend fun halfClose()
}

Android wires this to a ManagedChannel. iOS bridges through a generated Swift stub via cinterop. The proto messages themselves are serialized to ByteArray at the boundary — don’t pass generated JVM proto objects into commonMain.


Flow backpressure at the transport boundary

Kotlin’s Flow gives you structured backpressure, but gRPC’s reactive layer doesn’t automatically honor it. If your server emits faster than the client consumes, you’ll buffer without bound unless you wire onBackpressureDrop or implement explicit request(n) signaling.

The pattern that works in production:

// androidMain
fun openStream(): Flow<ServerMessage> = channelFlow {
    val call = stub.chat(object : StreamObserver<ServerMessage> {
        override fun onNext(value: ServerMessage) {
            trySend(value) // back-pressure via channel capacity
        }
        override fun onError(t: Throwable) { close(t) }
        override fun onCompleted() { close() }
    })
    awaitClose { call.halfClose() }
}.buffer(Channel.RENDEZVOUS) // force synchronous handoff

Channel.RENDEZVOUS ensures the producer blocks until the consumer is ready. Combine with conflate() only for UI state, never for business-critical messages.


Connection lifecycle and the reconnect thundering herd

Reconnect strategyConcurrent connections at 100k clients (2% reconnect)Backend impact
Immediate retry2,000 simultaneousThread pool exhaustion in <5s
Fixed 5s delay~2,000 staggered over 5sPartial relief, still spiky
Exponential backoff + jitter~40–80 concurrent at peakSustainable
Backoff + server-side connection capBounded regardlessResilient

Implement full jitter on the client side:

val delay = (baseMs * 2.0.pow(attempt)).toLong()
    .coerceAtMost(maxMs)
    .let { it / 2 + Random.nextLong(it / 2) }

On the Ktor backend, set an explicit maxConnectionAge on the NettyApplicationEngine and limit grpc.server.maxConnectionsPerIp at the Envoy/sidecar layer. Don’t rely on gRPC-Kotlin defaults — they’re tuned for service-to-service, not fan-out mobile workloads.


Half-close semantics: don’t skip this

Half-close means the client signals it will send no more messages but still expects server responses. On iOS, the gRPC-Swift library requires an explicit finish() call. On Android via grpc-kotlin, awaitClose in channelFlow handles it — but only if you don’t cancel the coroutine scope prematurely. Cancelling the scope before halfClose() sends a RST_STREAM, not a graceful FIN, and your server will log it as an error, not a clean client disconnect.


Three things I’d do differently from day one

Structure proto codegen as a platform split before writing any application code. Generate ByteArray-based contracts in commonMain and keep generated stubs in platform source sets. Retrofitting this is painful in ways that aren’t obvious until you’re already deep in.

Wire Channel.RENDEZVOUS at every gRPC-to-Flow boundary. Unbounded buffering is a silent memory leak that only surfaces under sustained stream load in production — staging traffic won’t catch it.

Cap and instrument your reconnect behavior before your first large deploy. Add exponential backoff with full jitter on the client, set maxConnectionAge on the server, and gate connection establishment with a server-side token bucket. The thundering herd isn’t theoretical at scale; it’s a deploy-day near-certainty.


Tags: grpc kmp kotlin multiplatform mobile backend architecture


Share: Twitter LinkedIn