Server-Sent Events for unidirectional server-to-client streaming, EventSource API, fetch streaming, reconnection patterns, message parsing
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.
EventSourcegives reconnection andLast-Event-IDreplay for free but is GET-only and cannot set headers; fetch streaming gives up both and buys custom headers, POST bodies and anAbortController. The facts that change the answer:EventSourceretries network errors but gives up permanently on an HTTP error status,retry:is milliseconds, andConnection: keep-aliveis prohibited on HTTP/2+.
Detailed Resources:
Last-Event-ID recovery, exponential backoff, health checks, visibility-aware pausingEventSource — 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.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.Both consume the same wire format, so the parser and the message types are shared between them.
<critical_requirements>
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:
Last-Event-IDEventSource cannot be usedHandled elsewhere:
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.
EventSource retries on its own schedule, adjustable by the server through retry:.Last-Event-ID and decides what to resend.data:, event:, id:, retry: and a bare : comment.CONNECTING (0) → OPEN (1) → messages… → CLOSED (2)
↓ ↓
(error) ← auto-reconnect ← (connection lost)
</philosophy>
<decision_framework>
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.
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>
Three handlers cover the whole surface, and readyState in onerror is what separates a retry in progress from a dead stream.
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
A message carrying an event: field is delivered to a listener of that name rather than to onmessage.
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
withCredentials sends cookies to another origin; a CORS misconfiguration surfaces as onerror with nothing more specific.
const eventSource = new EventSource(SSE_URL, { withCredentials: true });
Full code: examples/core.md
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.
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
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
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.
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
</patterns><red_flags>
Breaks at runtime:
close() when the consumer unmounts — the stream stays open, counts against the per-domain connection limit and keeps firing into a dead handler.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.Authorization header.EventSource where a POST is needed — it issues GET and nothing else.\n\n.TextDecoder used without { stream: true } — a multi-byte character split across chunks decodes as garbage.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.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.data: lines, not escaped newlines in one.id: clears Last-Event-ID rather than leaving the previous value in place.</red_flags>
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer