---
title: "SSE on Ktor: Replace 90% of Your WebSocket Use Cases"
published: true
description: "Build a production-ready Server-Sent Events backend with Ktor and a shared KMP client — automatic reconnection, Last-Event-ID replay, and zero custom sync logic."
tags: kotlin, android, mobile, api
canonical_url: https://blog.mvpfactory.co/sse-on-ktor-replace-90-percent-of-your-websocket-use-cases
---
## What We Will Build
Let me show you a pattern I use in every project that needs real-time server-to-client updates. We will build an SSE-powered order tracking endpoint in Ktor and a shared Kotlin Multiplatform client that runs identically on Android and iOS. By the end, you will have automatic reconnection, missed-event replay via `Last-Event-ID`, and backpressure handled by Kotlin Flows — all in roughly 30 lines of server code.
## Prerequisites
- Kotlin 1.9+ and Ktor 3.x (server and client)
- Kotlin Multiplatform project targeting Android and iOS
- Basic familiarity with Kotlin Flows and coroutines
- Ktor SSE plugin (`io.ktor:ktor-server-sse` and `io.ktor:ktor-client-sse`)
## Step 1: Understand Why SSE, Not WebSockets
Before writing code, here is the decision framework. Most mobile real-time features are unidirectional: the server pushes, the client renders. SSE is purpose-built for this.
| Capability | SSE | WebSocket |
|---|---|---|
| Direction | Server → Client | Bidirectional |
| Auto-reconnect | Built into spec | Manual implementation |
| Message replay | `Last-Event-ID` header | You build it yourself |
| Works through CDNs | Yes | Often needs config |
| Server complexity | ~30 lines Ktor | ~80 lines + ping/pong + state |
If your client is not streaming data *upstream* continuously, you are paying a complexity tax for nothing.
## Step 2: Build the Ktor SSE Endpoint
Here is the minimal setup to get this working. Install the SSE plugin and create a route backed by a Flow:
kotlin
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 critical piece is `Last-Event-ID`. When a client reconnects after a network drop, after Android Doze, after iOS suspends the app, it sends this header automatically. Your server replays from that sequence number. Zero client-side replay logic.
The `updatesFrom` function returns a `Flow<OrderEvent>` starting after the given sequence. Backpressure comes free from Flow's suspension: if the client cannot consume fast enough, the coroutine suspends. No buffer overflow, no dropped messages.
## Step 3: Build the KMP Shared Client
This is where Kotlin Multiplatform earns its keep. One SSE consumer, both platforms:
kotlin
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. When Android kills the connection during Doze mode, `retry` reconnects and the server replays from `Last-Event-ID`. On iOS, the same flow resumes on Background App Refresh. No platform-specific reconnection code.
## Step 4: Know the Boundary
SSE does not replace everything. Here is the honest line:
| Use Case | Right Choice |
|---|---|
| Notifications, live feeds, order tracking | **SSE** |
| Sync streams, server-push updates | **SSE** |
| Collaborative editing (multi-cursor) | WebSocket |
| Multiplayer games, binary streaming | WebSocket |
| High-frequency client-to-server input | WebSocket |
If the client streams structured data *upstream* continuously, use WebSockets. For roughly 90% of mobile real-time features, SSE is simpler and more resilient.
## Gotchas
**The docs do not mention this, but** `Last-Event-ID` only works if you actually assign `id` to every event. Skip the `id` field and replay silently breaks. Always use monotonic sequence numbers from your event store.
**Retry timing is server-controlled.** Send a `retry:` field in your SSE stream to tell clients how long to wait before reconnecting. The default is browser-dependent and usually too aggressive for mobile on spotty networks. Set it explicitly.
**Android Doze mode does not just throttle — it kills connections.** SSE handles this gracefully because reconnection is protocol-level. WebSocket implementations require custom heartbeat intervals, exponential backoff, and state reconciliation. That is the bug factory you do not need.
**Flow backpressure means slow clients will not crash your server**, but they *will* hold a coroutine suspended. Set a reasonable timeout on the server side so zombie connections do not accumulate.
## Conclusion
Default to SSE, not WebSockets. Audit your real-time features: if data flows server-to-client, SSE with `Last-Event-ID` gives you reconnection and replay with zero custom infrastructure. Build the client once in KMP with retry semantics, and both platforms handle connection lifecycle identically. Switch to WebSockets only when you hit a genuinely bidirectional requirement.
**Resources:**
- [Ktor SSE Plugin Docs](https://ktor.io/docs/server-server-sent-events.html)
- [MDN: Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
- [Kotlin Flows Guide](https://kotlinlang.org/docs/flow.html)
Top comments (0)