Share
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
Jasveen
Transcript
51 slides
-
SERVER-SENT EVENTS ON ANDROID Real-time streams that survive the factory
floor 現場で生き残るリアルタイムストリーム Jasveen Sandral • DroidKaigi 2026 -
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. -
A FROZEN CHART LOOKS EXACTLY LIKE A STOPPED MACHINE The
operator can't tell the difference. So the screen must not lie. -
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. -
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. -
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. -
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. -
TWO COLORS Green 保証 - the spec or the server
guarantees it Orange 責任 - your client must build it Watch the colors for the next forty minutes. -
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 -
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. -
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. -
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 -
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. -
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. -
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 -
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 -
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 -
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. -
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. -
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 -
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. -
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. -
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 -
“I'm extremely happy you're looking into the SSE functionality given
your experience!” rsamoilov · Rage maintainer · approving pull request #264 -
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 -
THE SURVIVAL KIT 復帰 The client side, in five small
files. All open source. -
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 -
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. -
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. -
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. -
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. -
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 -
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. -
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. -
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. -
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. -
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 -
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 -
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. -
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 -
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. -
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 -
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 -
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 -
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. -
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 -
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 -
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 -
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.