Consuming SSE on iOS and Android Permalink to this section
Part of Non-Browser SSE Clients, under Frontend Consumption & Client Patterns.
Native mobile apps use Server-Sent Events for the same features as web apps — live order status, chat, streaming AI answers, dashboards — and share the backend’s SSE endpoints with them. Neither iOS nor Android ships an EventSource, and both platforms impose rules that browsers do not: networking is suspended when the app is backgrounded, radios cost battery every time they wake, and default timeouts are tuned for API calls. This guide builds a correct client on each platform and handles the app lifecycle around it.
Symptom & Developer Intent Permalink to this section
- The stream stops after 60 seconds (iOS) or 10 seconds (Android) of quiet.
- After returning from the background, the live view shows stale data and never updates.
- Events that arrived while the app was backgrounded are lost.
- The app’s battery usage is flagged because the stream keeps the radio awake.
- Events are occasionally merged or never dispatched on iOS when using a line-based byte reader.
The intent is a mobile client that streams while the app is in the foreground, closes cleanly in the background, resumes exactly on return, and stays within reasonable battery use.
Root Cause Analysis Permalink to this section
Mobile operating systems suspend an app’s network activity shortly after it moves to the background (apart from specific background modes and system-managed transfers, which do not suit an open stream). The connection is either torn down or left dead; either way, events stop. The client must treat backgrounding as a planned disconnect and foregrounding as a reconnect with resume.
Timeouts: URLSessionConfiguration.timeoutIntervalForRequest (60 seconds by default) is an inactivity timeout between data packets, which a quiet stream exceeds; OkHttp’s readTimeout defaults to 10 seconds and behaves the same way. Both must be raised above the server’s heartbeat interval.
Event merging on iOS comes from line readers that skip empty lines. Empty lines are the dispatch signal in the event stream format, so a helper that drops them merges consecutive events. Parse the byte stream with a parser that keeps empty lines.
Step-by-Step Resolution Permalink to this section
Step 1 — iOS: stream bytes with URLSession and parse them yourself Permalink to this section
final class SSEClient {
private var task: Task<Void, Never>?
private(set) var lastEventID: String? = UserDefaults.standard.string(forKey: "sse.lastEventID")
func start(url: URL, token: @escaping () async -> String, onEvent: @escaping (SSEEvent) -> Void) {
task = Task {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60 // > 2 × server heartbeat (15 s)
let session = URLSession(configuration: config)
var delay: UInt64 = 1_000_000_000
while !Task.isCancelled {
var req = URLRequest(url: url)
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
req.setValue("Bearer \(await token())", forHTTPHeaderField: "Authorization")
if let id = lastEventID { req.setValue(id, forHTTPHeaderField: "Last-Event-ID") }
do {
let (bytes, response) = try await session.bytes(for: req)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw SSEError.badStatus }
var parser = SSEParser()
for try await byte in bytes { // raw bytes: keeps empty lines intact
for event in parser.feed(byte) {
if let id = event.id { lastEventID = id; UserDefaults.standard.set(id, forKey: "sse.lastEventID") }
await MainActor.run { onEvent(event) }
}
}
delay = 1_000_000_000
} catch { /* fall through to backoff */ }
try? await Task.sleep(nanoseconds: delay + UInt64.random(in: 0...delay / 3))
delay = min(delay * 2, 30_000_000_000)
}
}
}
func stop() { task?.cancel(); task = nil }
}
SSEParser is a small state machine that accumulates bytes into lines, decodes each line as UTF-8, and applies the field rules — the same algorithm as in parsing SSE from a fetch ReadableStream. Feeding it one byte at a time is simple; for high-rate streams, read in chunks for efficiency.
Step 2 — Android: OkHttp SSE with a long read timeout Permalink to this section
val client = OkHttpClient.Builder()
.readTimeout(60, TimeUnit.SECONDS) // > 2 × heartbeat; default 10 s would cut quiet streams
.retryOnConnectionFailure(true)
.build()
class StreamListener(private val store: EventStore) : EventSourceListener() {
override fun onEvent(source: EventSource, id: String?, type: String?, data: String) {
id?.let { store.lastEventId = it } // persisted, e.g. DataStore
store.dispatch(type ?: "message", data)
}
override fun onFailure(source: EventSource, t: Throwable?, response: Response?) {
val code = response?.code
if (code == 401 || code == 403) store.onAuthFailure() else store.scheduleReconnect()
}
override fun onClosed(source: EventSource) = store.scheduleReconnect()
}
fun open(store: EventStore): EventSource {
val request = Request.Builder()
.url("https://api.example.com/api/stream")
.header("Authorization", "Bearer ${store.token()}")
.apply { store.lastEventId?.let { header("Last-Event-ID", it) } }
.build()
return EventSources.createFactory(client).newEventSource(request, StreamListener(store))
}
okhttp-sse parses events but does not reconnect; scheduleReconnect applies jittered backoff and calls open again with the stored id.
Step 3 — Close on background, resume on foreground Permalink to this section
// iOS (SwiftUI)
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active: client.start(url: streamURL, token: auth.token, onEvent: store.apply)
case .background: client.stop()
default: break
}
}
// Android (lifecycle-aware)
ProcessLifecycleOwner.get().lifecycle.addObserver(LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> source = open(store)
Lifecycle.Event.ON_STOP -> source?.cancel()
else -> Unit
}
})
For events that must reach the user while the app is not running — a message, an order that shipped — send a push notification from the same event records. The stream is for the foreground; push is for everything else.
Step 4 — Keep battery cost down Permalink to this section
Every byte received wakes the radio. Heartbeats every 15 seconds keep a mobile radio in a higher power state for as long as the stream is open, which is acceptable in the foreground and a reason to close it in the background. For low-priority feeds, batch updates on the server (one event per few seconds rather than many per second) and avoid streaming at all on screens that do not need live data.
Validation & Monitoring Permalink to this section
Test on real devices, not only simulators: background suspension, network switching between Wi-Fi and cellular, and radio power behaviour differ. A useful manual script: open the live screen, background the app for two minutes while events are published, return, and confirm every missed event appears once. Record connection attempts, causes of failure and time-to-first-event after foregrounding in the app’s telemetry.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can an iOS or Android app keep an SSE stream open in the background?
Not reliably. Both systems suspend ordinary network activity in the background. Close the stream, use push for important events, and resume with Last-Event-ID when the app returns.
Is there a native EventSource on mobile?
No. Use URLSession's byte streams on iOS and OkHttp's SSE module on Android, with a small reconnect loop around each.
Should mobile apps use WebSockets instead?
For one-way feeds, SSE is simpler and shares the web app's endpoints. The lifecycle rules — close in background, resume on return — apply equally to WebSockets.
What about React Native or Flutter?
Use a native-backed SSE library, or bridge to the platform clients above. JavaScript fetch in React Native does not stream response bodies by default.