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-kotlinwith coroutines, Protobuf codegen set up - iOS:
grpc-swiftwith 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()
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
}
}
}
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)))
}
}
}
}
}
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)
}
}
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_idin 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)