DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Wiring gRPC Bidirectional Streaming to a Kotlin Multiplatform Mobile Client

---
title: "gRPC Bidirectional Streaming on Kotlin Multiplatform: Flow, Proto3, and the Connection Pool That Will Betray You in Production"
published: true
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."
tags: kotlin, android, mobile, architecture
canonical_url: https://mvpfactory.co/blog/grpc-bidi-streaming-kmp
---

## What We Are Building

By the end of this workshop, you will have a working gRPC bidirectional streaming transport layer on Kotlin Multiplatform — wired from a Ktor backend through a shared KMP module to a Compose Multiplatform UI. We will cover proto3 codegen strategy, Flow-based backpressure at the native transport boundary, half-close semantics on both platforms, and the connection pool exhaustion pattern that takes down backends on deploy day.

## Prerequisites

- Kotlin Multiplatform project with `androidMain` and `iosMain` source sets
- Familiarity with Kotlin coroutines and `Flow`
- A running Ktor backend (gRPC-Kotlin plugin configured)
- `protoc` and `grpc-kotlin` installed for codegen

---

## Step 1: Structure Your Proto Codegen as a Platform Split

Here is a pattern I use in every KMP project that touches gRPC. The standard `protoc` + `grpc-kotlin` toolchain does not produce KMP-compatible artifacts. The temptation is to generate JVM targets and wrap them with `expect`/`actual`. Do not do this — it does not scale.

Structure your modules like this:

Enter fullscreen mode Exit fullscreen mode

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


In `commonMain`, define only the data contract and stream interface. Pass `ByteArray` at the boundary — never let generated JVM proto objects cross into common code.

Enter fullscreen mode Exit fullscreen mode


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


Android wires this to a `ManagedChannel`. iOS bridges through a generated Swift stub via `cinterop`. Set this up before writing any application code. Retrofitting it is painful in ways that only become obvious once you are already deep in.

---

## Step 2: Wire Flow Backpressure at the Transport Boundary

Kotlin's `Flow` gives you structured backpressure. gRPC's reactive layer does not automatically honor it. If your server emits faster than the client consumes, you will buffer without bound. Here is the minimal setup to get this working correctly on Android:

Enter fullscreen mode Exit fullscreen mode


kotlin
// androidMain
fun openStream(): Flow = channelFlow {
val call = stub.chat(object : StreamObserver {
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` forces the producer to block until the consumer is ready. Use `conflate()` only for UI state — never for business-critical messages.

---

## Step 3: Implement Reconnect With Full Jitter

A single bidi stream holds a persistent HTTP/2 connection. At 100k mobile clients, a 2% simultaneous reconnect event produces 2,000 concurrent connection establishment attempts. Without a ceiling on your server-side connection pool, thread exhaustion follows within seconds.

| Reconnect strategy | Concurrent connections (2% of 100k) | Backend impact |
|---|---|---|
| Immediate retry | 2,000 simultaneous | Thread pool exhaustion in <5s |
| Fixed 5s delay | ~2,000 staggered over 5s | Partial relief, still spiky |
| Exponential backoff + jitter | ~40–80 concurrent at peak | Sustainable |
| Backoff + server-side connection cap | Bounded regardless | Resilient |

The docs do not mention this, but the gRPC-Kotlin server defaults are tuned for service-to-service traffic, not fan-out mobile workloads. Implement full jitter on the client:

Enter fullscreen mode Exit fullscreen mode


kotlin
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 `NettyApplicationEngine` and limit `grpc.server.maxConnectionsPerIp` at the Envoy/sidecar layer.

---

## Gotchas

**Half-close semantics differ by platform.** On iOS, gRPC-Swift requires an explicit `finish()` call. On Android, `awaitClose` in `channelFlow` handles it — but only if you do not cancel the coroutine scope prematurely. Cancelling the scope before `halfClose()` sends a `RST_STREAM`, not a graceful `FIN`. Your server logs it as an error, not a clean disconnect.

**Unbounded buffering is a silent memory leak.** Staging traffic will not catch it. It surfaces under sustained stream load in production. Wire `Channel.RENDEZVOUS` at every gRPC-to-Flow boundary from day one.

**The thundering herd is not theoretical.** It is a deploy-day near-certainty at scale. Cap connection establishment server-side with a token bucket and add instrumentation before your first large deploy.

---

## Conclusion

Bidirectional gRPC streaming on KMP is production-ready today, but only if you make three decisions early: platform-split your proto codegen, enforce backpressure explicitly at the transport boundary, and bound your reconnect behavior before load hits. Get those right upfront and the rest of the stack follows cleanly.

**Further reading:**
- [gRPC-Kotlin docs](https://grpc.io/docs/languages/kotlin/)
- [gRPC-Swift half-close semantics](https://github.com/grpc/grpc-swift)
- [Kotlin Channels and backpressure](https://kotlinlang.org/docs/channels.html)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)