MVP Factory
ai startup development

gRPC Bidirectional Streaming in Mobile Apps: Connection Resumption, Deadline Propagation, and the Flow Control Architecture That Handles Unreliable Networks

KW
Krystian Wiewiór · · 4 min read

The problem with real-time on mobile

Every team building real-time features (chat, live tracking, collaborative editing) eventually faces the same decision. After building production systems handling 50K+ concurrent mobile streams, I think this choice matters more than most people give it credit for.

CriteriaREST Polling (1s)WebSocketgRPC Bidi Stream
Bandwidth (msg/min)~120 KB~8 KB~6 KB
Latency (p95)500-1000ms30-80ms25-70ms
Type safetyManualManualProtobuf codegen
BackpressureNoneManualNative (HTTP/2)
Reconnect complexityLowMediumHigh
Battery impact (idle)HighMediumLow (tuned)

gRPC wins on bandwidth and latency. But that “High” reconnect complexity? That’s where most teams get burned on mobile.

Keepalive tuning: respecting the 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.

Most teams get this wrong. They use server defaults (typically 2-hour TCP keepalive or 30s gRPC pings) without thinking about mobile at all.

// 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’re waking the radio for zero-value pings. The 60-second interval balances connection liveness against the ~12-second RRC promotion cost on LTE.

The reconnection state machine

Network transitions (WiFi to cellular, tunnel entry, elevator) aren’t edge cases on mobile. They’re 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 thing people miss: 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)))
                }
            }
        }
    }
}

Deadline propagation through interceptors

Deadlines prevent zombie streams from leaking resources. On mobile, 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)
    }
}

This ensures backgrounded or battery-constrained streams fail fast rather than holding resources indefinitely.

Backpressure: let HTTP/2 do the work

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 can’t keep up.

What to do with all this

Tune keepalives for cellular radios. Set keepAliveWithoutCalls(false), use 60s+ intervals, and disable pings when the app is backgrounded. This alone can reduce battery drain from streaming by 40%.

Build a resumption state machine, not a retry loop. Bake sequence_id into your protobuf contract from the start. Exponential backoff with jitter and a 30-second cap handles most mobile network transitions gracefully.

Propagate deadlines contextually. Background streams get short deadlines. Foreground streams get longer ones. Low battery gets aggressive timeouts. An interceptor makes this transparent to feature code.

I think 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. The architecture is on you.


Share: Twitter LinkedIn