web-realtime-sse · git:20260906.085a8e7 · 2026-09-06 · sha256 c819bf2c816a40ff
web-realtime-sse git:20260906.085a8e7A
Immutable. This exact content is served forever at /api/v1/blob/c819bf2c816a40ff.
---
name: web-realtime-sse
description: Server-Sent Events for unidirectional server-to-client streaming, EventSource API, fetch streaming, reconnection patterns, message parsing
---
# Server-Sent Events (SSE) Patterns
> **Quick Guide:** SSE pushes text from server to client over an ordinary HTTP response, so it crosses proxies and firewalls that block anything more exotic. `EventSource` gives reconnection and `Last-Event-ID` replay for free but is GET-only and cannot set headers; fetch streaming gives up both and buys custom headers, POST bodies and an `AbortController`. The facts that change the answer: `EventSource` retries network errors but gives up permanently on an HTTP error status, `retry:` is milliseconds, and `Connection: keep-alive` is prohibited on HTTP/2+.
**Detailed Resources:**
- [examples/core.md](examples/core.md) — EventSource lifecycle, named events, credentials, a state-tracking wrapper, typed messages, and the React hooks built on them
- [examples/fetch-streaming.md](examples/fetch-streaming.md) — stream reader with buffer handling, field parser, auth headers, POST streaming, token-by-token UI
- [examples/reconnection.md](examples/reconnection.md) — `Last-Event-ID` recovery, exponential backoff, health checks, visibility-aware pausing
- [reference.md](reference.md) — message format, field behaviour, readyState values, required response headers, EventSource behaviour table
---
## Which path applies
- **`EventSource`** — the browser reconnects, replays through `Last-Event-ID` and parses the wire format for you. It sends GET only, sets no headers, and authenticates by cookie (`withCredentials: true`). Start at [examples/core.md](examples/core.md).
- **Fetch streaming** — reach for it when the stream needs an `Authorization` header, a POST body, or cancellation you control. You then own reconnection, backoff, `Last-Event-ID` and the field parsing. See [examples/fetch-streaming.md](examples/fetch-streaming.md).
Both consume the same wire format, so the parser and the message types are shared between them.
---
<critical_requirements>
## Before writing SSE code
**Call `eventSource.close()` when the consumer goes away.** An open stream holds a connection against the browser's per-domain limit and keeps delivering into a handler nothing is watching.
**Branch on `readyState` inside `onerror`.** `CONNECTING` means the browser is already retrying and the right action is to wait; `CLOSED` means it has given up and reconnecting is yours to do.
**Emit an `id:` on each message from the server.** The browser returns the last one as `Last-Event-ID` on the next connection, which is what lets the server resume rather than restart.
**Respond with `Content-Type: text/event-stream` and `Cache-Control: no-cache`.** Leave `Connection: keep-alive` off — it is prohibited on HTTP/2 and above, and Safari rejects a response carrying it.
**Send a comment line (`: keep-alive`) on an interval.** Proxies close streams they read as idle, typically after 60–120 seconds, and a comment resets that clock without reaching any handler.
</critical_requirements>
---
**Auto-detection:** EventSource, text/event-stream, Last-Event-ID, eventSource.onmessage, eventSource.readyState, EventSource.CONNECTING, withCredentials, addEventListener("message"), retry:, data:, event:, id:, ReadableStream, TextDecoder, response.body.getReader
**Applies to:**
- Server-to-client push over plain HTTP — notifications, feeds, dashboards
- Token-by-token streaming of generated text
- Live data feeds where the client only listens
- Resumable streams via `Last-Event-ID`
- Parsing the SSE wire format by hand when `EventSource` cannot be used
**Handled elsewhere:**
- Frequent client-to-server messaging — SSE carries no upstream channel, so a client that needs one either pairs the stream with ordinary requests or wants a bidirectional transport instead of this.
- Binary payloads — the wire format is UTF-8 text; binary has to be encoded, which costs about a third in size.
- The server's own stream implementation and its replay store.
- Where messages are kept once received, and how they render.
- Issuing and refreshing the token the stream authenticates with.
---
<philosophy>
SSE is an HTTP response that never ends. That is the whole design, and everything follows from it: it works through the infrastructure that already carries HTTP, it is readable on the wire, and the browser can own reconnection because there is no handshake to redo.
- **The browser reconnects, not you** — `EventSource` retries on its own schedule, adjustable by the server through `retry:`.
- **Replay is a header** — the server sees `Last-Event-ID` and decides what to resend.
- **The format is five fields** — `data:`, `event:`, `id:`, `retry:` and a bare `:` comment.
```
CONNECTING (0) → OPEN (1) → messages… → CLOSED (2)
↓ ↓
(error) ← auto-reconnect ← (connection lost)
```
</philosophy>
---
<decision_framework>
## Authenticating the stream
A cookie on a same-origin or credentialed cross-origin request is the only mechanism `EventSource` offers — set `withCredentials: true` and have the server allow credentials in CORS. A bearer token needs fetch streaming, because the token belongs in an `Authorization` header rather than the URL. Short-lived tokens additionally need the reconnect path to fetch a fresh one, which is another reason that case lands on fetch streaming.
## Deploying behind infrastructure
On HTTP/1.1 a stream occupies one of roughly six connections per domain, so several concurrent streams starve the rest of the page; HTTP/2 multiplexes them and removes the ceiling. Reverse proxies buffer responses by default and will hold messages until the buffer fills — turn buffering off for the route (`X-Accel-Buffering: no` on nginx) and avoid transformations with `Cache-Control: no-transform`. On a serverless platform, check the response timeout before relying on a long-lived stream at all.
</decision_framework>
---
<patterns>
## Core patterns
### Pattern 1: EventSource lifecycle
Three handlers cover the whole surface, and `readyState` in `onerror` is what separates a retry in progress from a dead stream.
```typescript
const eventSource = new EventSource(SSE_URL);
eventSource.onopen = () => setStatus("open");
eventSource.onmessage = (event: MessageEvent) =>
handle(event.data, event.lastEventId);
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CLOSED) reconnectManually();
};
```
Full code: [examples/core.md](examples/core.md)
---
### Pattern 2: Named event types
A message carrying an `event:` field is delivered to a listener of that name rather than to `onmessage`.
```typescript
eventSource.addEventListener("notification", (event: MessageEvent) => {
show(JSON.parse(event.data));
});
// messages with no event: field still arrive here
eventSource.onmessage = (event: MessageEvent) => handleDefault(event.data);
```
Full code: [examples/core.md](examples/core.md)
---
### Pattern 3: Credentials and cross-origin
`withCredentials` sends cookies to another origin; a CORS misconfiguration surfaces as `onerror` with nothing more specific.
```typescript
const eventSource = new EventSource(SSE_URL, { withCredentials: true });
```
Full code: [examples/core.md](examples/core.md)
---
### Pattern 4: Connection state and manual retry
`EventSource` retries network failures by itself but stops permanently on an HTTP error status. Tracking status gives the UI something to show and gives that case somewhere to hook a retry.
```typescript
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CLOSED) {
setStatus("closed");
scheduleRetry(); // the browser will not do this one
} else {
setStatus("error"); // CONNECTING — the browser is already on it
}
};
```
Full code: [examples/core.md](examples/core.md)
---
### Pattern 5: SSE message format
Fields are `\n`-separated and a message ends at `\n\n`. Five field types: `data:` payload, `event:` name, `id:` recovery point, `retry:` reconnect interval in milliseconds, and a bare `:` comment.
```
event: notification
data: {"title": "New message"}
id: msg-002
: keep-alive comment (never delivered to a handler)
```
Repeated `data:` lines join with `\n`; `id:` persists until a later message changes it; `retry:` is remembered for every subsequent reconnection.
Full field and behaviour tables: [reference.md](reference.md)
---
### Pattern 6: Typed message handling
A discriminated union over the payload turns the switch into an exhaustive one, so a new server message type becomes a compile error rather than a silently ignored branch.
```typescript
type SSEMessage =
| { type: "notification"; title: string; body: string }
| { type: "user-update"; userId: string; action: "joined" | "left" }
| { type: "heartbeat"; serverTime: number };
function handle(message: SSEMessage): void {
switch (message.type) {
case "notification":
return show(message.title, message.body);
case "user-update":
return updatePresence(message.userId, message.action);
case "heartbeat":
return updateServerTime(message.serverTime);
default: {
const exhaustive: never = message;
return exhaustive;
}
}
}
```
Full code: [examples/core.md](examples/core.md)
</patterns>
---
<red_flags>
## Red flags
**Breaks at runtime:**
- No `close()` when the consumer unmounts — the stream stays open, counts against the per-domain connection limit and keeps firing into a dead handler.
- A new `EventSource` created without closing the previous one — both stay live and every message arrives twice.
- `onerror` left unhandled — a failed stream is indistinguishable from a quiet one, and the UI shows stale data indefinitely.
- `JSON.parse` on `event.data` without a `try` — one malformed message takes down the handler for every message after it.
- A token in the URL query string — it is logged by the server, kept in history and visible to proxies — use a cookie, or fetch streaming with an `Authorization` header.
- `EventSource` where a POST is needed — it issues GET and nothing else.
- Fetch streaming that treats each chunk as a whole message — chunk boundaries fall mid-message, so buffer and split on `\n\n`.
- `TextDecoder` used without `{ stream: true }` — a multi-byte character split across chunks decodes as garbage.
- Rendering message content without validating it — the payload is attacker-influenced text, and a typed interface is a compile-time claim rather than a runtime one.
**Surprising behaviour:**
- `EventSource` has no timeout — a connection dead at the network level can stay `OPEN` for minutes before `onerror` fires, which is what keep-alive comments and a client-side health check exist to catch.
- It retries network errors but treats an HTTP 4xx or 5xx as final, so the case most likely to need a retry is the one it will not perform.
- `retry:` is milliseconds. A server sending `retry: 5` reconnects every 5ms.
- `data:\n\n` delivers an empty string rather than nothing — a falsy check treats a real message as absent.
- Multi-line payloads are several `data:` lines, not escaped newlines in one.
- A blank `id:` clears `Last-Event-ID` rather than leaving the previous value in place.
- On reconnection the stream resumes but component state does not reset itself, so anything accumulated before the drop needs reconciling against what the replay delivers.
</red_flags>