MVP Factory
ai startup development

gRPC-Web + Kotlin Multiplatform: Protobuf at scale

KW
Krystian Wiewiór · · 4 min read

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.

MetricJSON/RESTProtobuf/gRPC-Web
Avg payload size (same schema)1.0x (baseline)0.35x
Parse time per message (mobile)1.0x~0.45x
Schema enforcementRuntimeCompile-time
Streaming supportPolling / SSENative bidirectional
Code generationManual / OpenAPIbuf 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

  1. Gate on buf lint in CI before merging any .proto changes. Schema drift between mobile client and server is cheaper to prevent than to debug in production.

  2. 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.

  3. Tie stream lifecycle to your UI scope, not to a manual flag. Leaked gRPC streams drain server connections quietly — structured concurrency with viewModelScope or equivalent handles cleanup automatically.


Tags: kotlin kmp grpc mobile backend


Share: Twitter LinkedIn