---
title: "gRPC-Web + Kotlin Multiplatform: Protobuf at Scale"
published: true
description: "Wire gRPC-Web to a KMP shared client layer using buf CLI, expect/actual transport boundaries, coroutine streaming lifecycle, and benchmarks showing protobuf beats REST above 10k req/s."
tags: kotlin, mobile, architecture, performance
canonical_url: https://mvpfactory.co/blog/grpc-web-kotlin-multiplatform-protobuf-scale
---
What You Will Build
By the end of this tutorial you will have a working Kotlin Multiplatform client wired to a gRPC-Web backend — with buf CLI generating your Protobuf descriptors, platform-correct transport behind an expect/actual boundary (OkHttp on Android, URLSession on iOS), and bidirectional streaming tied to your coroutine lifecycle. You will also walk away with the numbers that tell you exactly when this migration is worth the operational overhead.
Prerequisites
- KMP project targeting Android and iOS
-
bufCLI installed (brew install bufbuild/buf/bufor equivalent) - Familiarity with Kotlin coroutines and
Flow - A gRPC-Web-compatible backend (Envoy proxy or native gRPC-Web server)
Step 1 — Generate Protobuf Descriptors with buf CLI
Let me show you a pattern I use in every project: skip hand-rolling .proto files and skip invoking protoc directly. The buf CLI gives you a reproducible, lint-enforced pipeline.
# buf.gen.yaml
version: v1
plugins:
- plugin: buf.build/grpc/kotlin
out: shared/src/commonMain/kotlin
opt: lite
- plugin: buf.build/grpc/java
out: shared/src/androidMain/kotlin
Run buf generate from your repo root. The lite option on the Kotlin plugin keeps the generated runtime lean — on mobile the full Protobuf runtime adds overhead you do not need.
Add buf lint to your CI gate immediately. Schema drift between client and server is cheap to prevent and genuinely painful to debug at scale.
Step 2 — The expect/actual Transport Boundary
This is where KMP's platform split matters most. gRPC-Web requires an HTTP/1.1-compatible transport — browsers and mobile HTTP stacks do not support HTTP/2 framing natively — so channel construction must live behind an expect/actual boundary.
// commonMain
expect fun buildGrpcChannel(host: String, port: Int): ManagedChannel
// androidMain
actual fun buildGrpcChannel(host: String, port: Int): ManagedChannel =
OkHttpChannelBuilder.forAddress(host, port)
.usePlaintext() // TLS terminated at proxy in prod
.build()
// iosMain
actual fun buildGrpcChannel(host: String, port: Int): ManagedChannel =
GRPCChannelPool.sharedPool().channel(
withHost: host, port: port
) // URLSession-backed transport
Keep these actual implementations thin — channel construction only, no business logic.
Step 3 — Bidirectional Streaming Lifecycle in Coroutines
The gRPC-Kotlin stub exposes Flow-based APIs natively, mapping cleanly onto KMP's coroutine layer.
// commonMain shared business logic
class ProductStreamRepository(private val stub: CatalogServiceCoroutineStub) {
fun observeInventory(skus: Flow<SkuRequest>): Flow<InventoryUpdate> =
stub.streamInventory(skus)
.catch { e -> emit(InventoryUpdate.error(e.message)) }
.flowOn(Dispatchers.IO)
}
Tie stream cancellation to your UI scope:
viewModelScope.launch {
repository.observeInventory(requestFlow)
.collect { update -> _state.value = update }
} // Cancelled automatically on ViewModel clear
viewModelScope handles cleanup automatically when the ViewModel is cleared. Do not manage stream lifecycle manually.
Gotchas
Leaked gRPC streams are silent killers. I have seen mobile apps leak open streams across navigation events and quietly exhaust server connection limits under load. Always bind stream lifecycle to a structured scope — viewModelScope on Android, the equivalent lifecycle-aware scope on iOS.
Business logic in platform actuals defeats KMP. The moment you put logic into androidMain or iosMain transport implementations, you create platform divergence that compounds over time. Keep actuals to channel construction only.
The buf CLI crossover point matters. At low request rates the operational overhead of Protobuf — schema management, generated code, buf pipeline — outweighs the gains. The crossover in production systems sits around 5–10k req/s per service.
The Numbers
| Metric | JSON/REST | Protobuf/gRPC-Web |
|---|---|---|
| Avg payload size | 1.0x | 0.35x |
| Parse time per message (mobile) | 1.0x | ~0.45x |
| Schema enforcement | Runtime | Compile-time |
| Streaming support | Polling / SSE | Native bidirectional |
Above 10k req/s, the wire size reduction hits egress costs directly. The parse CPU reduction means fewer compute instances. Compile-time schema enforcement shrinks the runtime error surface at high call volumes.
Conclusion
Three things to take away: gate on buf lint in CI before any .proto merge, keep expect/actual implementations to channel construction only, and tie stream lifecycle to your UI scope — not to a manual flag. The docs do not emphasize that last one loudly enough, but it is the gotcha that will save you hours in production.
Further reading: buf CLI docs, grpc-kotlin coroutine stubs, KMP expect/actual reference
Top comments (0)