Slide 1

Slide 1 text

SERVER-SENT EVENTS ON ANDROID Real-time streams that survive the factory floor 現場で生き残るリアルタイムストリーム Jasveen Sandral • DroidKaigi 2026

Slide 2

Slide 2 text

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.

Slide 3

Slide 3 text

A FROZEN CHART LOOKS EXACTLY LIKE A STOPPED MACHINE The operator can't tell the difference. So the screen must not lie.

Slide 4

Slide 4 text

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.

Slide 5

Slide 5 text

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.

Slide 6

Slide 6 text

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.

Slide 7

Slide 7 text

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.

Slide 8

Slide 8 text

TWO COLORS Green 保証 - the spec or the server guarantees it Orange 責任 - your client must build it Watch the colors for the next forty minutes.

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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.

Slide 11

Slide 11 text

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.

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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.

Slide 14

Slide 14 text

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.

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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.

Slide 19

Slide 19 text

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.

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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.

Slide 22

Slide 22 text

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.

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

“I'm extremely happy you're looking into the SSE functionality given your experience!” rsamoilov · Rage maintainer · approving pull request #264

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

THE SURVIVAL KIT 復帰 The client side, in five small files. All open source.

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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.

Slide 29

Slide 29 text

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.

Slide 30

Slide 30 text

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.

Slide 31

Slide 31 text

TWO BACKPRESSURE RECIPES fun Flow.latestOnly(): Flow = conflate() FlowRecipes.kt fun Flow.boundedLog(capacity: Int = 128): Flow = 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.

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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.

Slide 34

Slide 34 text

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.

Slide 35

Slide 35 text

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.

Slide 36

Slide 36 text

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.

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

THE VERDICT 判定

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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.

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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.

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

APPENDIX 付録 Backup slides for questions.

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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.

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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.