Server-Sent Events as Your Mobile Real-Time Layer: Automatic Reconnection, Last-Event-ID Recovery, and Why SSE on Ktor Replaces 90% of Your WebSocket Use Cases
TL;DR
WebSockets are the default answer for real-time mobile features, but they’re the wrong default. Server-Sent Events (SSE) give you automatic reconnection, message replay via Last-Event-ID, HTTP cache compatibility, and work through every proxy and CDN on the planet, all over a plain GET request. I’ve spent enough time building production mobile backends to know that SSE on Ktor with Kotlin Flows handles notifications, live feeds, order tracking, and sync streams with half the code and a fraction of the operational headaches. I’ll walk through the server, the KMP shared client, and where the boundary actually is.
Pick the right primitive
Paul Graham once argued that Lisp’s power came from choosing the right primitives, that most programs didn’t need the complexity they carried, and simpler foundations outperformed brute-force approaches. Real-time mobile architectures work the same way. WebSockets are a full-duplex TCP abstraction. SSE is a unidirectional stream over HTTP. Most mobile real-time features are unidirectional: the server pushes, the client renders.
Compare them side by side.
| Capability | SSE | WebSocket |
|---|---|---|
| Protocol | HTTP/1.1+ GET | Custom upgrade over HTTP |
| Direction | Server → Client | Bidirectional |
| Auto-reconnect | Built into spec | Manual implementation |
| Message replay | Last-Event-ID header | Manual implementation |
| Works through CDNs/proxies | Yes | Often requires config |
| HTTP caching | Yes (standard headers) | No |
| Mobile OS handling (Doze/BAR) | Reconnects naturally | Requires heartbeat + reconnect logic |
| Complexity (server) | ~30 lines Ktor | ~80 lines + ping/pong + state |
If your feature doesn’t require the client to stream data to the server continuously, you’re paying a complexity tax for nothing.
Ktor server: SSE with backpressure via Flow
Ktor’s SSE support maps directly onto Kotlin Flows, which gives you backpressure for free. Here’s a production-shaped endpoint:
fun Route.sseOrderUpdates() {
sse("/orders/{orderId}/events") {
val orderId = call.parameters["orderId"] ?: return@sse
val lastEventId = call.request.headers["Last-Event-ID"]?.toLongOrNull()
orderRepository.updatesFrom(orderId, afterSequence = lastEventId)
.collect { event ->
send(ServerSentEvent(
data = json.encodeToString(event.payload),
event = event.type,
id = event.sequence.toString()
))
}
}
}
The part that matters most: Last-Event-ID. When the client reconnects after a network drop, after Android Doze mode releases, after iOS Background App Refresh kicks in, the browser or client library sends this header automatically. Your server replays missed events from that sequence number. Zero client-side replay logic. Zero custom sync protocols.
The updatesFrom function returns a Flow<OrderEvent> that queries from the event store starting after the given sequence. Backpressure is handled by Flow’s suspension semantics: if the client can’t consume fast enough, the coroutine suspends. No buffer overflow, no dropped messages.
KMP shared client: one stream, both platforms
This is where Kotlin Multiplatform earns its keep. A shared SSE consumer using Ktor Client:
class SseEventSource(private val httpClient: HttpClient) {
fun connect(url: String, lastEventId: String? = null): Flow<SseEvent> = flow {
httpClient.sse(url) {
lastEventId?.let { header("Last-Event-ID", it) }
} {
incoming.collect { event ->
emit(SseEvent(
id = event.id,
type = event.event,
data = event.data
))
}
}
}.retry(3) { cause ->
cause is IOException
delay(1000)
true
}
}
This runs identically on Android and iOS. On Android, when the OS kills the connection during Doze mode, the retry operator reconnects and the server replays from Last-Event-ID. On iOS, the same flow resumes when Background App Refresh triggers. No platform-specific reconnection code.
Connection lifecycle: mobile reality
Here’s what most teams get wrong: they treat mobile connections as stable. They aren’t. Android Doze mode throttles network access after minutes of inactivity. iOS suspends background connections aggressively. WebSocket implementations require custom heartbeat intervals, ping/pong handlers, exponential backoff, and state reconciliation on every reconnect. It’s a lot of code for something the protocol should just handle.
SSE does handle it at the protocol level. The reconnection delay is server-configurable via the retry: field. The replay mechanism is standardized. Your mobile code doesn’t know or care why it disconnected. It reconnects, sends Last-Event-ID, and picks up where it left off.
When you actually need WebSockets
SSE doesn’t replace everything. Here’s the honest boundary:
| Use Case | Right Choice |
|---|---|
| Live feeds, notifications, order tracking | SSE |
| Sync streams, server-push updates | SSE |
| Collaborative editing (multi-cursor) | WebSocket |
| Real-time multiplayer games | WebSocket |
| Binary streaming (audio/video) | WebSocket |
| Client-to-server high-frequency input | WebSocket |
If the client needs to continuously stream structured data upstream, use WebSockets. For everything else, and that’s roughly 90% of mobile real-time features in production apps I’ve worked on, SSE is simpler, more resilient, and operationally cheaper.
What to do with this
Default to SSE, not WebSockets. Audit your real-time features. If the data flows server-to-client, SSE with Last-Event-ID gives you reconnection and replay with zero custom infrastructure. Switch to WebSockets only when you hit a genuinely bidirectional requirement.
Use Ktor’s Flow-backed SSE for backpressure. Map your event store to a Flow, assign monotonic sequence IDs, and let Last-Event-ID drive replay. This eliminates an entire class of sync bugs that plague custom WebSocket implementations.
Build the SSE client once in KMP. A shared SseEventSource with retry semantics handles Android Doze and iOS background suspension identically. Platform-specific reconnection logic is the bug factory you don’t need.