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.

An SSE stream across the mobile app lifecycle State diagram of a mobile SSE client moving between streaming in the foreground, closed while backgrounded, and resuming with Last-Event-ID when the app returns to the foreground. An SSE stream across the mobile app lifecycle FOREGROUND streaming BACKGROUND stream closed RESUMING Last-Event-ID app backgrounded app active replay done
The stream's life follows the app's. The persisted last event id bridges the gap so nothing published while backgrounded is lost.

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.

Stream in the foreground, push in the background Two panels describing the SSE stream's role while the app is active and push notifications' role while it is backgrounded or closed. Stream in the foreground, push in the background Foreground: SSE stream live updates, sub-second resumes from Last-Event-ID closed when backgrounded Background: push important events only delivered by the OS tap opens app, stream resumes
Neither channel replaces the other. The last event id ties them together, so the stream resumes after whatever push already announced.

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.

Time from foreground to first live event Bar chart comparing time to first live event after returning from the background with no resume, with Last-Event-ID resume, and with resume plus a cached snapshot shown immediately. Time from foreground to first live event Reconnect, no resume 2.8 s (and data lost) Resume with Last-Event-ID 1.1 s Cached snapshot + resume 0.2 s median seconds until the user sees current data, cellular network
Showing cached state instantly and then replaying the gap makes the return feel immediate, even though the network work is the same.

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.