DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Wiring gRPC-Web to a Kotlin Multiplatform Backend

---
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
---
Enter fullscreen mode Exit fullscreen mode

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
  • buf CLI installed (brew install bufbuild/buf/buf or 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// androidMain
actual fun buildGrpcChannel(host: String, port: Int): ManagedChannel =
    OkHttpChannelBuilder.forAddress(host, port)
        .usePlaintext() // TLS terminated at proxy in prod
        .build()
Enter fullscreen mode Exit fullscreen mode
// iosMain
actual fun buildGrpcChannel(host: String, port: Int): ManagedChannel =
    GRPCChannelPool.sharedPool().channel(
        withHost: host, port: port
    ) // URLSession-backed transport
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Tie stream cancellation to your UI scope:

viewModelScope.launch {
    repository.observeInventory(requestFlow)
        .collect { update -> _state.value = update }
} // Cancelled automatically on ViewModel clear
Enter fullscreen mode Exit fullscreen mode

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)