gRPC-Web + Kotlin Multiplatform: Protobuf at scale
Meta 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.
TL;DR
If you are running a Kotlin Multiplatform client against a high-throughput backend, switching from REST+JSON to gRPC-Web+Protobuf is not premature optimization — it is a serialization budget decision. At >10k req/s, wire size and CPU parse cost compound fast. This post covers the full stack: buf CLI descriptor generation, the expect/actual transport split (OkHttp on Android, URLSession on iOS), bidirectional streaming lifecycle in coroutines, and the numbers that justify the migration.
The problem with REST at scale
Here is what most teams get wrong about REST+JSON: JSON parsing is not free. At low traffic it is invisible. At scale it becomes a measurable CPU budget line. In production systems I have worked on, switching a high-frequency endpoint to Protobuf reduced payload sizes by 60–70% and cut mobile parse time by roughly half.
| Metric | JSON/REST | Protobuf/gRPC-Web |
|---|---|---|
| Avg payload size (same schema) | 1.0x (baseline) | 0.35x |
| Parse time per message (mobile) | 1.0x | ~0.45x |
| Schema enforcement | Runtime | Compile-time |
| Streaming support | Polling / SSE | Native bidirectional |
| Code generation | Manual / OpenAPI | buf CLI + plugins |
Step 1: Protobuf descriptor generation with buf CLI
Skip hand-rolling .proto files and manually invoking protoc. 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 — worth it for mobile where the full Protobuf runtime adds overhead you do not need.
Add buf lint to your CI gate early. Schema drift between client and server is cheap to prevent and genuinely painful to debug in production.
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 for gRPC), so your 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
The iOS side leans on the gRPC-Swift transport under the hood via a thin interop layer. Keep the 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, which maps cleanly to KMP’s coroutine layer.
// Shared business logic in commonMain
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)
}
One lifecycle rule worth taking seriously: cancel the upstream Job when your ViewModel or presenter is destroyed. gRPC-Web streams hold server resources. I have seen mobile apps leak open streams across navigation events, eventually exhausting server connection limits under load.
viewModelScope.launch {
repository.observeInventory(requestFlow)
.collect { update -> _state.value = update }
} // Cancelled automatically on ViewModel clear
Using viewModelScope or the equivalent lifecycle-aware scope on iOS gives you automatic cancellation. Do not manage stream lifecycle manually.
The serialization budget at >10k req/s
At low request rates, the operational overhead of Protobuf — schema management, generated code, buf pipeline — outweighs the gains. The crossover point in production systems I have observed sits around 5–10k req/s per service. Above that threshold, wire size reduction hits egress costs directly in cloud environments. Parse CPU reduction means fewer compute instances. Compile-time schema enforcement shrinks the runtime error surface at high call volumes.
On the backend: a Kotlin server using grpc-kotlin with coroutine-native stubs handles backpressure through Flow naturally, without callback pyramids or manual thread management.
3 takeaways
-
Gate on
buf lintin CI before merging any.protochanges. Schema drift between mobile client and server is cheaper to prevent than to debug in production. -
Keep the expect/actual transport implementations to channel construction only. Business logic in platform-specific actuals defeats the purpose of KMP’s shared layer and creates divergence over time.
-
Tie stream lifecycle to your UI scope, not to a manual flag. Leaked gRPC streams drain server connections quietly — structured concurrency with
viewModelScopeor equivalent handles cleanup automatically.
Tags: kotlin kmp grpc mobile backend