DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

gRPC Bidirectional Streaming for Mobile Apps: A Practical Workshop

What We Will Build

In this workshop, I will walk you through implementing gRPC bidirectional streaming for real-time mobile features — chat, live tracking, collaborative editing — on both Android and iOS. By the end, you will have a reconnection state machine that survives network transitions, keepalive settings tuned for cellular radios, deadline propagation through interceptors, and backpressure strategies using Kotlin Flows and Swift AsyncSequence.

Let me show you a pattern I use in every project that handles 50K+ concurrent mobile streams.

Prerequisites

  • Android: grpc-kotlin with coroutines, Protobuf codegen set up
  • iOS: grpc-swift with Swift concurrency (async/await)
  • Familiarity with Protocol Buffers and HTTP/2 basics
  • A gRPC server that supports offset-based stream resumption

Step 1: Understand Why gRPC Wins (and Where It Hurts)

Before writing code, here is why we are choosing gRPC over the alternatives:

Criteria REST Polling (1s) WebSocket gRPC Bidi Stream
Bandwidth (msg/min) ~120 KB ~8 KB ~6 KB
Latency (p95) 500-1000ms 30-80ms 25-70ms
Type safety Manual Manual Protobuf codegen
Backpressure None Manual Native (HTTP/2)
Reconnect complexity Low Medium High
Battery impact (idle) High Medium Low (tuned)

gRPC wins on bandwidth and latency. But that "High" reconnect complexity? That is where most teams get burned on mobile. Let me show you how to tame it.

Step 2: Tune Keepalives for the Cellular Radio State Machine

Cellular radios cycle through RRC states: CONNECTED, SHORT_DRX, LONG_DRX, IDLE. Each transition takes 5-12 seconds and eats battery. Aggressive keepalives force the radio back to CONNECTED, which kills battery life.

Here is the minimal setup to get this working:

// Android — grpc-kotlin channel configuration
val channel = ManagedChannelBuilder.forAddress(host, port)
    .keepAliveTime(60, TimeUnit.SECONDS)      // balance: not too aggressive
    .keepAliveTimeout(10, TimeUnit.SECONDS)
    .keepAliveWithoutCalls(false)              // critical: no pings when idle
    .idleTimeout(5, TimeUnit.MINUTES)
    .build()
Enter fullscreen mode Exit fullscreen mode

Setting keepAliveWithoutCalls(false) is non-negotiable on mobile. Without it, you are waking the radio for zero-value pings. The 60-second interval balances connection liveness against the ~12-second RRC promotion cost on LTE. This alone can reduce battery drain from streaming by 40%.

Step 3: Build the Reconnection State Machine

Network transitions (WiFi to cellular, tunnel entry, elevator) are not edge cases on mobile. They are the norm. You need a state machine, not a retry loop.

sealed class StreamState {
    object Connected : StreamState()
    data class Reconnecting(val attempt: Int, val lastOffset: Long) : StreamState()
    object BackingOff : StreamState()
    object Suspended : StreamState()  // app backgrounded
}

fun <T> Flow<T>.withReconnection(
    resumeToken: () -> Long,
    connect: (Long) -> Flow<T>
): Flow<T> = flow {
    var offset = resumeToken()
    var attempt = 0
    while (currentCoroutineContext().isActive) {
        try {
            connect(offset).collect { msg ->
                attempt = 0
                offset = extractOffset(msg)
                emit(msg)
            }
        } catch (e: StatusException) {
            if (e.status.code == Status.Code.UNAVAILABLE) {
                delay(backoff(++attempt))  // exponential: 500ms, 1s, 2s, cap 30s
            } else throw e
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The docs do not mention this, but your server protocol must support offset-based resumption. Without it, reconnection means replaying the entire stream or losing messages. Design your protobuf messages with a sequence_id field from day one.

On iOS with grpc-swift, the same pattern maps to AsyncSequence:

func resumableStream(from offset: Int64) -> AsyncThrowingStream<Update, Error> {
    AsyncThrowingStream { continuation in
        Task {
            var currentOffset = offset
            var attempt = 0
            while !Task.isCancelled {
                do {
                    for try await msg in client.subscribe(.with { $0.resumeFrom = currentOffset }) {
                        currentOffset = msg.sequenceID
                        attempt = 0
                        continuation.yield(msg)
                    }
                } catch let status as GRPCStatus where status.code == .unavailable {
                    attempt += 1
                    try await Task.sleep(for: .milliseconds(min(500 * (1 << attempt), 30_000)))
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Propagate Deadlines Through Interceptors

Deadlines prevent zombie streams from leaking resources. Here is the gotcha that will save you hours: propagate deadlines through a client interceptor that attaches context-aware timeouts.

class DeadlineInterceptor : ClientInterceptor {
    override fun <Req, Resp> interceptCall(
        method: MethodDescriptor<Req, Resp>,
        callOptions: CallOptions,
        next: Channel
    ): ClientCall<Req, Resp> {
        val deadline = when {
            isBackground() -> callOptions.withDeadlineAfter(10, TimeUnit.SECONDS)
            isLowBattery() -> callOptions.withDeadlineAfter(30, TimeUnit.SECONDS)
            else -> callOptions.withDeadlineAfter(120, TimeUnit.SECONDS)
        }
        return next.newCall(method, deadline)
    }
}
Enter fullscreen mode Exit fullscreen mode

Backgrounded or battery-constrained streams fail fast rather than holding resources indefinitely. The interceptor makes this transparent to feature code.

Step 5: Let HTTP/2 Handle Backpressure

gRPC's HTTP/2 foundation provides flow control windows at both connection and stream levels. On Android with coroutine Flows, backpressure propagates naturally: a slow collector pauses the producer. AsyncSequence does the same on iOS. The rule is simple: never buffer unboundedly. Use Flow.buffer(capacity = 64) or equivalent, and drop-oldest when the UI cannot keep up.

Gotchas

  • Forgetting keepAliveWithoutCalls(false): This is the single most common battery drain mistake. It sends pings even when no streams are active, constantly waking the cellular radio.
  • Retry loops instead of state machines: A simple retry loop does not account for app backgrounding, battery state, or offset tracking. You will lose messages or waste resources.
  • Missing sequence_id in your protobuf contract: If you add resumption later, it is a breaking protocol change. Bake it in from the start.
  • Uniform deadlines: A 120-second deadline makes sense in the foreground. In the background, it holds a connection open for two minutes doing nothing. Use context-aware deadlines.
  • Unbounded buffering: Without a capacity limit, a burst of server messages while the UI is frozen will blow up memory. Always cap your buffer.

Conclusion

gRPC bidirectional streaming is the best option for real-time mobile features, but only if you respect the constraints of unreliable networks and battery-limited devices. The protocol gives you the primitives — HTTP/2 flow control, multiplexing, structured contracts. The architecture is on you: tune keepalives for cellular radios, build a resumption state machine, propagate deadlines contextually, and never buffer unboundedly.

Start with the channel configuration and sequence_id in your protobuf. Everything else builds on those two decisions.

Top comments (0)