Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Server-Sent Events on Android: Real-Time Stream...

Avatar for Jasveen Jasveen
September 15, 2026

Server-Sent Events on Android: Real-Time Streams That Survive the Factory Floor - DroidKaigi 2026

What Server-Sent Events guarantee, and what an Android client has to build on top. A dead server sends no FIN, so liveness needs a heartbeat and a watchdog. Resume needs both sides: monotonic ids and a retention buffer on the server, Last-Event-ID on the client. Reconnect policy lives in one pure reducer you can test as data. Plus backpressure recipes, going offline mid-shift, lifecycle, and SSE vs WebSocket.

Presented at DroidKaigi 2026 (September 2, 2026) in Tokyo, Japan.

Topics covered:
- What SSE guarantees vs what your client must build
- okhttp-sse: no reconnect, no resume, and the retry hint it drops
- Heartbeat + watchdog: the read-timeout trap
- Last-Event-ID resume, on the wire
- Four server bugs from open-source work on the Rage framework, four client rules
- The reconnect state machine as one pure function
- Backpressure: conflate gauges, bound logs
- Offline, lifecycle, and an honest screen
- SSE vs WebSocket for one-way monitoring

Code, tests and the chaos server: https://github.com/jsxs0/sse-survival-kit-android
GitHub: https://github.com/jsxs0
X/Twitter: https://x.com/SJasveen

Avatar for Jasveen

Jasveen

September 15, 2026

More Decks by Jasveen

Other Decks in Technology

Transcript

  1. SERVER-SENT EVENTS ON ANDROID Real-time streams that survive the factory

    floor 現場で生き残るリアルタイムストリーム Jasveen Sandral • DroidKaigi 2026
  2. ONE YEAR AGO DroidKaigi 2025 · Building Industrial-Grade RFID Systems

    "How does the data stream from the factory floor to the Android dashboard, in real time?" This was the most asked question after that talk. This talk is the answer.
  3. A FROZEN CHART LOOKS EXACTLY LIKE A STOPPED MACHINE The

    operator can't tell the difference. So the screen must not lie.
  4. TODAY'S TOPICS 1. What SSE guarantees, and what your client

    must build 2. Where Android reconnects for you, and where it does not 3. Backpressure: when the server is faster than the device 4. Offline: when factory WiFi drops mid-shift 5. The error recovery state machine 6. SSE vs WebSocket: when to use each 7. A working pattern you can clone This is the list from the session description. We check them off as we go.
  5. MONITORING IS ONE-WAY Factory Server ↓ Android Dashboard The server

    sends speeds, temperatures, and alarms. The device listens. For the rare message back, a normal HTTP POST is enough.
  6. CONNECTION OPTIONS Polling - simple, but always one interval late

    WebSocket - two-way, more infrastructure to support Server-Sent Events - one-way stream over plain HTTP gRPC streaming is in the appendix. Comparison at the end.
  7. THE WIRE $ curl -N http://localhost:3000/telemetry : synthetic factory telemetry,

    every value is generated captured live · the opening frames, byte for byte retry: 3000 event: retry-hint data: 3000 id: 1 event: telemetry data: {"line":"B1","conveyor_speed_mms":181,"motor_temp_c":64.3,"ts":"2026-07-07T03:35:45.392Z","synthetic":true} data, id, event, retry, and comment lines. A blank line sends the event. This is the whole protocol. You can read it in a terminal.
  8. TWO COLORS Green 保証 - the spec or the server

    guarantees it Orange 責任 - your client must build it Watch the colors for the next forty minutes.
  9. THE BROWSER GETS HELP. ANDROID DOES NOT. ON THE WIRE

    ON ANDROID + A simple text format + id: on every event, retry: hints + In the browser, EventSource reconnects for you - No EventSource in the platform - id: and retry: handling is your code - Reconnect, resume, liveness: your code
  10. NO EVENTSOURCE ON ANDROID OkHttp 5 is stable. Its SSE

    module is still marked experimental. Ktor 3.1+ has reconnection built in. Today I use okhttp-sse: it does the least for you, so these patterns work with any library.
  11. OKHTTP-SSE DOES NOT RECONNECT SseClient.kt · the listener's last callback

    override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { val code = response?.code val cause = when (code) { 401, 403 -> Disconnect.AuthRejected(code) else -> Disconnect.TransportError(t, code) } signals.trySend(SseSignal.WentDown(cause, clock())) } After onFailure, the library is done. It won't retry, won't resume, and hasn't scheduled anything. Every line inside this method is our own code taking over. One more surprise: the parser reads the retry: field and never gives it to you.
  12. THE READ TIMEOUT TRAP SseClient.kt private val client: OkHttpClient =

    baseClient.newBuilder() .readTimeout(heartbeatMillis * (missedBeats + 1), TimeUnit.MILLISECONDS) .retryOnConnectionFailure(false) .build() 10 seconds (default) - kills a healthy but quiet stream Zero - a dead connection is never detected heartbeat × (missed + 1) - based on the server's heartbeat promise
  13. A DEAD SERVER SENDS NO FIN When a server loses

    power, the socket on the phone still looks open. Reads never return, and you get neither an exception nor a callback. The app keeps showing old data.
  14. HEARTBEAT AND WATCHDOG SERVER CLIENT telemetry_controller.rb conn.write(EventLog.append(Telemetry.sample)) tick += 1

    if (tick % HEARTBEAT_EVERY).zero? conn.write("event: heartbeat\ndata: {}\n\n") end A real event, not a comment line. Your listener never sees comments, so a comment heartbeat can't feed the watchdog. HeartbeatWatchdog.kt @Synchronized fun onSignal() { patrol?.cancel() patrol = scope.launch { delay(heartbeatMillis * missedBeats) onSilentStall() } } If this timer fires, the stream is dead, even though nothing raised an error.
  15. LAST-EVENT-ID captured live · reconnect after seeing id 5 ·

    trimmed $ curl -N -H "Last-Event-ID: 5" http://localhost:3000/telemetry id: 6 event: telemetry data: {"line":"B1","conveyor_speed_mms":174,"motor_temp_c":66.1,"ts":"2026-07-07T03:35: 50 .401Z","synthetic":true} id: 7 event: telemetry data: {"line":"A1","conveyor_speed_mms":166,"motor_temp_c":64.8,"ts":"2026-07-07T03:35: 51 .407Z","synthetic":true} ⋮ id: 9 event: telemetry data: {"line":"B1","conveyor_speed_mms":173,"motor_temp_c":66.2,"ts":"2026-07-07T03:36: 08 .564Z","synthetic":true} Replay - ids 6 to 8 arrive together. Check the timestamps. Live - id 9 arrives eighteen seconds later, in real time
  16. CHECKPOINT 1 ✓ What SSE guarantees, and what your client

    must build ✓ Android never reconnects for you Backpressure Offline, mid-shift The state machine SSE vs WebSocket A working pattern
  17. THE OTHER SIDE OF THE WIRE 反対側 Your question sent

    me to the server side. In spring 2026 I contributed four merged pull requests to the SSE layer of Rage, a Ruby web framework. You don't need Ruby today. Read the code as pseudocode. #245 Add unit tests for SSE::ConnectionProxy · +118 · 25 Mar #248 Ensure connection is closed when raw SSE stream raises · +71 · 30 Mar #264 Ensure connection is closed for single-value SSE streams · +42/-1 · 7 Apr #267 Add tests for SSE log context propagation across fiber boundaries · +64 · 9 Apr
  18. TESTS FIRST spec/sse/application_spec.rb · the three behaviors of #248 it

    "closes the connection when the proc raises an exception" it "does not close the connection on normal completion" it "does not interfere when the proc closes the connection itself" I started with a 118-line test suite (#245), because tests are how I learn a codebase. These three sentences define the whole connection lifecycle. The fixes exist to make them pass.
  19. SERVER BUGS SHOW UP ON YOUR PHONE Two weeks in

    that codebase: two fixes, one mistake a reviewer caught, and one behavior I had to prove with tests. Each one became a rule for the Android client.
  20. BUG 1: NO CLOSE ON ERROR lib/rage/sse/application.rb · merged in

    #248 def start_raw_stream(connection) @stream.call(Rage::SSE::ConnectionProxy.new(connection)) rescue => e connection.close if connection.open? raise e end Your phone saw - silence on a socket that looks healthy Rule 1 - the watchdog is not optional
  21. BUG 2: MY FIRST FIX WAS WRONG my first commit,

    reconstructed from the review · never merged def start_raw_stream(connection) @stream.call(Rage::SSE::ConnectionProxy.new(connection)) ensure connection.close end It closes async streams before they write one byte. The maintainer, reviewing #248: "a reasonable but incorrect assumption about the synchronous nature of raw SSE streams." Your phone would have seen - healthy streams dying right after opening Rule 2 - a disconnect is not an error. Resume quietly.
  22. BUG 3: ONE LEAK PER FAILED REQUEST before #264 ·

    verbatim def send_data(connection) Rage::Telemetry.tracer.span_sse_stream_process(connection:, type: @type) do connection.write(Rage::SSE.__serialize(@stream)) connection.close end end Any error above the close skips it. The merged fix moves close into an ensure. This work is synchronous, so ensure is correct here. The opposite of Bug 2. Your phone saw - every device failing at the same time Rule 3 - back off with full jitter. Don't reconnect in waves.
  23. BUG 4: LOGS WITHOUT A REQUEST ID the mechanism #267

    pins down in tests # capture, at initialization @log_tags, @log_context = Fiber[:__rage_logger_tags], Fiber[:__rage_logger_context] # restore, inside the streaming fiber Fiber.schedule do Fiber[:__rage_logger_tags], Fiber[:__rage_logger_context] = @log_tags, @log_context In Kotlin terms: losing your CoroutineContext when you switch dispatchers. Your phone saw - a bug report the backend can't trace Rule 4 - send a client stream id with every connect
  24. “I'm extremely happy you're looking into the SSE functionality given

    your experience!” rsamoilov · Rage maintainer · approving pull request #264
  25. FOUR BUGS, FOUR RULES WHAT THE SERVER TAUGHT YOUR CLIENT'S

    RULE An error left the connection open Watchdog on the heartbeat My ensure closed healthy streams Resume quietly with Last-Event-ID A skipped close leaked, then everything failed Backoff with full jitter Log context was lost across fibers Send a stream id
  26. THE STATE MACHINE IDLE 200 OK CONNECTING OPEN events flowing

    · id tracked Last-Event-ID · replay, then live 401 / 403 onFailure · timeout watchdog · closed delay elapsed · has id RESUMING Last-Event-ID: 41 no id yet RETRY_WAIT backoff + full jitter FAILED terminal reconnect now, skip the wait NetworkCallback
  27. ONE PURE FUNCTION SseState.kt is SseSignal.WentDown -> when { //

    Already down (or done): cancelling a stalled source fires onFailure, // and that echo must not escalate the attempt or restart the timer. state is SseState.RetryWait || state is SseState.Failed -> state signal.cause is Disconnect.AuthRejected -> SseState.Failed(signal.cause) state is SseState.Open && signal.atMillis - state.sinceMillis >= stableOpenMillis -> SseState.RetryWait(attempt = 1, signal.cause, state.lastEventId) else -> SseState.RetryWait(state.attempt + 1, signal.cause, state.lastEventId) } SseSignal.RetryElapsed, SseSignal.NetworkBack -> if (state is SseState.RetryWait) state.redial() else state Only a stable connection resets the backoff; a stream that keeps dropping waits longer. It never touches a socket, a timer or a random number, so its tests are ordinary asserts.
  28. BACKOFF WITH FULL JITTER Backoff.kt fun delayFor(attempt: Int, serverRetryHintMillis: Long?

    = null): Long { val base = serverRetryHintMillis ?: baseMillis val shift = min(attempt - 1, MAX_SHIFT).coerceAtLeast(0) val ceiling = min(capMillis, base shl shift) return random.nextLong(ceiling + 1).coerceAtLeast(floorMillis) } When a server restarts, every device loses its stream at the same time. Without a random delay, they all reconnect at the same time, again and again. The green line uses the server's own retry hint when we have one.
  29. SSE HAS NO FLOW CONTROL The server pushes. If the

    device is slower than the production line, something must be dropped. Decide what gets dropped. Don't let a buffer decide for you.
  30. TWO BACKPRESSURE RECIPES fun <T> Flow<T>.latestOnly(): Flow<T> = conflate() FlowRecipes.kt

    fun <T> Flow<T>.boundedLog(capacity: Int = 128): Flow<T> = buffer(capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) Gauges - only the newest value matters. Conflate skips to the latest reading. Alarm logs - history matters. A bounded buffer drops the oldest first. An unbounded queue on an eight-hour stream will run out of memory. Parse on IO. Render at the screen's own pace.
  31. OFFLINE, MID-SHIFT ConnectivityManager wiring · README val callback = object

    : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { trySend(Unit) } } cm.registerDefaultNetworkCallback(callback) Detect - with the watchdog. Roaming often kills the socket silently. Queue - nothing. The server does the sending. Resume - when the network returns, reconnect immediately
  32. SHOW THE AGE OF THE DATA 181 mm/s last update

    00:07 ago RECONNECTING One timestamp, one ticker, one subtraction (DataAge.kt in the kit). The number dims, the age counts up, the badge shows the state. Never freeze a chart silently.
  33. RESUME NEEDS BOTH SIDES SERVER: KEEP A WINDOW CLIENT: SEND

    THE BOOKMARK event_log.rb frame = "id: #{id}\nevent: #{event}\n" \ "data: #{payload.to_json}\n\n" @frames << [id, frame] @frames.shift while @frames.size > MAX_RETAINED The buffer size decides how long an outage you can resume from. Agree on it with the backend team. SseClient.kt Request.Builder() .url(url) .header("Accept", "text/event-stream") .apply { if (lastEventId != null) header("Last-Event-ID", lastEventId) } .build() If the outage was longer than the buffer: fetch a REST snapshot first, then stream.
  34. A SHIFT IS EIGHT HOURS Foreground service - a ViewModel

    collector dies with the screen Persist the newest id - process death becomes a normal resume Screen off? Disconnect on purpose - reconnecting later is cheap Doze and App Standby will pause your app. Plan for them.
  35. DEMO: BREAKING THE SERVER curl -X POST /chaos/silent the stream

    freezes, the socket stays open curl -X POST /chaos/kill then resume with Last-Event-ID: the replay ./demo-test.sh the reducer's ten tests, run live If the live demo fails, I have a recording of the same run.
  36. CHECKPOINT 2 ✓ What SSE guarantees, and what your client

    must build ✓ Android never reconnects for you ✓ Backpressure: decide what gets dropped ✓ Offline: detect it, show it, reconnect fast ✓ The state machine: one reducer, tested as data SSE vs WebSocket A working pattern
  37. SSE VS WEBSOCKET SERVER-SENT EVENTS WEBSOCKET + One-way, which matches

    monitoring + Plain HTTP: proxies and auth just work + Resume with id: and Last-Event-ID, built in - Text only - Liveness is your job (heartbeat + watchdog) + Two-way + Binary frames, ping/pong built in - Upgrade handshake; every proxy must cooperate - Resume is yours to design and version - Two-way, which monitoring does not need
  38. ONE-WAY MONITORING: SSE. THE DEVICE TALKS BACK: WEBSOCKET. HTTP/1.1 -

    few connections per host. Long streams block other requests. HTTP/2 - streams share one connection. Problem gone. Check which one your load balancer speaks.
  39. THE PRODUCTION PATTERN SERVER 保証 CLIENT 責任 SHOW THE STATE

    heartbeat events on a schedule monotonic id: per stream retention buffer + replay retry: hints closes what it opens state machine, one pure reducer watchdog on the heartbeat read timeout from the heartbeat, not the default backoff, capped, with full jitter Last-Event-ID + reconnect on network return conflate the gauges bound the logs, drop oldest connection badge always visible show the age of the data
  40. ALL OF THIS IS ON GITHUB github.com/jsxs0/sse-survival-kit-android The Kotlin client,

    the reducer tests, and the chaos server. The server runs on the same Rage code from the four pull requests. Synthetic data only. MIT license.
  41. SEVEN OF SEVEN ✓ What SSE guarantees, and what your

    client must build ✓ Android never reconnects for you ✓ Backpressure: decide what gets dropped ✓ Offline: detect it, show it, reconnect fast ✓ The state machine: one reducer, tested as data ✓ SSE for one-way; WebSocket when the device talks back ✓ A working pattern, on GitHub now
  42. THANK YOU ありがとうございました。ご安全に! Last year you asked how the data

    reaches the phone in real time. This talk was the answer. Ask the Speaker · @SJasveen github.com/jsxs0/sse-survival-kit-android
  43. WHY NOT GRPC STREAMING? Needs a clean HTTP/2 path end

    to end - factory proxies often break it Resume - yours to design; nothing like Last-Event-ID for free Where it wins - typed contracts, and a backend that is already gRPC
  44. THE KTOR OPTION Ktor 3.x sketch · check current docs

    before shipping val client = HttpClient(OkHttp) { install(SSE) { maxReconnectionAttempts = 4 } } client.sse("http://factory.local/telemetry") { incoming.collect { event -> render(event.data) } } Reconnection and Last-Event-ID are built in since Ktor 3.1. One caveat on the OkHttp engine: reconnection can duplicate events (KTOR-9023), so dedupe by id. The watchdog, backpressure, honest UI, and lifecycle rules stay the same.
  45. BATTERY COST Every packet can wake the radio - and

    the radio stays on for seconds after Longer heartbeat interval - cheaper battery, slower stall detection Screen off - disconnect on purpose, resume by bookmark
  46. TOKENS EXPIRE MID-STREAM Send credentials as headers - SSE is

    plain HTTP; never the query string 401 lands in FAILED on purpose - refresh the token, rebuild the client, resume Rotate before expiry - refresh the token before it expires, then reconnect
  47. WHY NOT FCM? Delivery - best effort, throttled, unordered No

    stream semantics - no ids, no resume, no schedule Good for - a high-priority ping that wakes the app to reconnect
  48. DATA AGE IN COMPOSE Compose sketch val age by tracker.ages().collectAsStateWithLifecycle(initialValue

    = null) Text( text = age?.let { "last update ${formatAge(it)} ago" } ?: "waiting for data", ) formatAge() is in the kit: 427 seconds becomes "07:07". Pair it with the connection badge so stale data is always visible.