| name | server-sent-events |
| description | Choose between Server-Sent Events (SSE) and WebSockets for mobile realtime; implement SSE correctly. Use when considering unidirectional streaming from server to app. |
Server-Sent Events (SSE) for Mobile
Instructions
SSE is a one-way, text-based streaming protocol over HTTP. It is simpler than WebSockets and works with existing HTTP infrastructure (proxies, CDNs, standard auth). Use it when the server-to-client stream dominates and the client rarely sends anything back.
1. SSE vs WebSocket
| Concern | SSE | WebSocket |
|---|
| Direction | server -> client only | bidirectional |
| Transport | HTTP/1.1 chunked or HTTP/2 | dedicated upgrade |
| Reconnection | built-in (Last-Event-ID) | manual |
| Proxy/CDN friendliness | high (plain HTTP) | mixed |
| Framing | text only, event-based | text or binary |
| Auth | standard Authorization header, cookies | typically first-frame token |
| Mobile native support | fair (needs a client lib on iOS/Android) | good |
| Backpressure | HTTP-level | explicit |
Pick SSE when: server pushes events (feeds, notifications, progress, AI token stream) and the client rarely replies. Pick WebSockets when: chat-like interactivity, binary frames, or sub-250 ms two-way latency.
2. Server Implementation
Minimal SSE response:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
Connection: keep-alive
X-Accel-Buffering: no
Each event:
id: 4812
event: message
data: {"thread":"thr_01HX7","body":"Ada: see you at 5"}
A blank line terminates the event. Node/TS (Express):
app.get("/v1/feed/stream", auth, async (req, res) => {
res.status(200).set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-store",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders();
const lastId = Number(req.header("last-event-id") ?? 0);
const cursor = await stream.since(req.user.id, lastId);
const ping = setInterval(() => res.write(": ping\n\n"), 20_000);
req.on("close", () => { clearInterval(ping); cursor.close(); });
for await (const event of cursor) {
res.write(`id: ${event.seq}\n`);
res.write(`event: ${event.type}\n`);
res.write(`data: ${JSON.stringify(event.data)}\n\n`);
}
});
Send comment lines (: ping) as keepalives so intermediaries do not close the idle connection.
3. Reconnection and Last-Event-ID
The SSE protocol specifies automatic reconnection with the last seen id sent as Last-Event-ID header. Implement this on the server: query events with seq > last_event_id and stream them.
Ensure ids are monotonic per stream (usually per user / channel).
4. Auth
Use standard Authorization: Bearer <jwt>. For long-lived connections, refresh the access token in a parallel HTTP call; the SSE connection does not need to renegotiate unless auth fails server-side.
If the client library does not let you set custom headers (e.g., browser EventSource), fall back to a signed query token:
GET /v1/feed/stream?stream_token=<short-lived jwt scoped to stream>
Mobile WebView / native libs typically allow custom headers; prefer that.
5. HTTP/2 and HTTP/3
SSE works fine over HTTP/2 and HTTP/3. Under HTTP/2, multiple SSE streams can share a connection. Avoid HTTP/1.1 with Cloudflare-style 100-connection per-origin limits when many streams per user are needed.
6. Mobile Client
No native EventSource on iOS/Android; use a small parser on top of a streaming HTTP client.
Swift (URLSession streaming):
var req = URLRequest(url: URL(string: "https://api.example.com/v1/feed/stream")!)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("\(lastEventId)", forHTTPHeaderField: "Last-Event-ID")
let (bytes, _) = try await URLSession.shared.bytes(for: req)
var buf = ""
for try await line in bytes.lines {
if line.isEmpty { parseEvent(buf); buf = ""; continue }
buf += line + "\n"
}
Kotlin (OkHttp + okhttp-sse):
val client = OkHttpClient()
val req = Request.Builder()
.url("https://api.example.com/v1/feed/stream")
.header("Authorization", "Bearer $token")
.header("Last-Event-ID", lastSeq.toString())
.build()
EventSources.createFactory(client).newEventSource(req, object : EventSourceListener() {
override fun onEvent(es: EventSource, id: String?, type: String?, data: String) {
handle(id, type, data)
}
})
7. Reliability
- Send a heartbeat comment every 15–25 s to prevent idle-connection kills.
- On mobile backgrounding, expect the OS to suspend the connection; reconnect on resume with
Last-Event-ID.
- Cap server-side replay (e.g., 5 minutes or 1000 events). Beyond that, respond with a one-shot
event: reset so the client performs a full refetch.
8. When SSE Is the Wrong Tool
- Binary streams (audio, video frames) -- use WebSockets or a proper media protocol.
- Very high event rates per client (> 50/s sustained) -- HTTP framing overhead dominates.
- Two-way chat with sub-100 ms latency.
Checklist