DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Backpressure-Aware SSE Reconnection in Mobile Clients

---
title: "Backpressure-Aware SSE Reconnection for Mobile Clients"
published: true
description: "Build an SSE consumer in Kotlin Flow that survives mobile network transitions using WAL-backed buffers, exponential backoff with jitter, and backpressure handling."
tags: kotlin, android, mobile, architecture
canonical_url: https://blog.mvpfactory.co/backpressure-aware-sse-reconnection-for-mobile
---

## What We Will Build

Today we are building a resilient Server-Sent Events consumer for Android using Kotlin Flow. By the end of this workshop, you will have an SSE client that persists events to a local Write-Ahead Log, reconnects intelligently across network transitions, and applies backpressure when the UI falls behind the event stream. No more silent message loss when your user walks from wifi into an elevator.

## Prerequisites

- Kotlin coroutines and Flow basics
- Room database familiarity
- An existing SSE server endpoint to connect to
- Android `ConnectivityManager` awareness

## Step 1: Understand Why Standard EventSource Breaks on Mobile

Here is the gotcha that will save you hours. During a cellular-to-wifi handoff, three things happen in sequence: the TCP connection drops silently (no FIN, no RST), the OS detects the new network after 2–15 seconds, and the `EventSource` reconnects. Events emitted during that gap are gone.

The W3C spec defines `Last-Event-ID` as the recovery mechanism. In practice, most servers use bounded in-memory buffers. Node.js `sse-channel` keeps 500 events in a FIFO ring buffer. Go `r3labs/sse` retains 1000 events for 5 minutes. Spring `SseEmitter` has no replay support at all. If your client disconnects longer than the server retains history, `Last-Event-ID` returns nothing. No error. Just silence.

## Step 2: Implement Exponential Backoff with Jitter

The docs do not mention this, but fixed retry intervals create thundering herd problems. With 10,000 clients reconnecting on a fixed 3-second retry, all connections land in a single 100ms window. Full jitter spreads them across 15 seconds — a 150x reduction in peak load.

Here is the minimal setup to get this working:

Enter fullscreen mode Exit fullscreen mode


kotlin
fun reconnectDelay(attempt: Int, baseMs: Long = 1000L, maxMs: Long = 30_000L): Long {
val exponential = baseMs * 2.0.pow(attempt.coerceAtMost(5)).toLong()
val capped = exponential.coerceAtMost(maxMs)
val jitter = (capped * Random.nextDouble(0.5, 1.0)).toLong()
return jitter
}


Backoff without jitter just shifts the thundering herd to a later time slot. Always include jitter.

## Step 3: Build the Kotlin Flow Architecture

Let me show you a pattern I use in every project. Three layers connected via Kotlin Flows with explicit backpressure:

Enter fullscreen mode Exit fullscreen mode


kotlin
class ResilientSseConsumer(
private val db: WalDatabase,
private val connectivity: ConnectivityMonitor
) {
fun events(): Flow = channelFlow {
connectivity.networkState.collectLatest { state ->
if (state.isConnected) {
val lastId = db.walDao().lastEventId()
sseConnect(lastId)
.onEach { event ->
db.walDao().insert(event.toWalEntry())
}
.buffer(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
.collect { send(it) }
}
}
}.flowOn(Dispatchers.IO)
}


Every received event gets written to a Room database WAL before delivery to the UI. This survives process death. `collectLatest` is the key operator — it cancels the current connection on network change and establishes a fresh one with the correct `Last-Event-ID`, ensuring only one active SSE connection exists at any time.

The `.buffer(capacity = 64, onBufferOverflow = SUSPEND)` applies backpressure upstream when the UI cannot consume fast enough. The SSE read loop pauses, TCP flow control kicks in, and the server naturally slows delivery. No dropped messages. No unbounded memory growth.

## Step 4: Handle the Gap Between Server and Client

Even with WAL persistence, the server may have evicted events your client has not received. Embed a sequence number in each event and compare on reconnection:

Enter fullscreen mode Exit fullscreen mode


kotlin
if (firstEvent.sequence - lastPersistedSequence > 1) {
val fullState = api.getFullState()
db.walDao().replaceAll(fullState)
}


This hybrid approach — SSE for real-time, REST for gap recovery — is the only pattern I have seen work reliably in production across flaky mobile networks.

## Gotchas

- **Don't trust `Last-Event-ID` alone.** Persist event IDs in a local WAL and implement sequence gap detection with a REST fallback for full state recovery.
- **Don't rely on `EventSource` reconnection.** It is unaware of Android network lifecycle and will maintain zombie connections during handoffs. Use `collectLatest` with `ConnectivityManager` instead.
- **Never skip backpressure.** Unbounded event buffering on mobile leads to OOM crashes under burst traffic. Let Flow's structured concurrency propagate backpressure through TCP flow control to the server.
- **Backoff without jitter is not backoff.** You are just rescheduling the stampede.

## Wrapping Up

You now have a production-ready pattern for resilient SSE on mobile: WAL-backed persistence, jittered exponential backoff, network-aware reconnection via `collectLatest`, and bounded buffers with `SUSPEND` backpressure. This is the architecture that stops silent message loss — the kind of loss that teams spend 90% of their effort on server push and roughly 0% thinking about on the client side. Ship it with confidence.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)