Frontend Consumption & Client Patterns Permalink to this section
Receiving a Server-Sent Events stream in the browser looks deceptively simple β new EventSource(url) and you are done. The hard parts surface in production: the connection silently dies behind a corporate proxy, a React component leaks a socket on every re-render, a backgrounded mobile tab freezes the stream for ten minutes, and the user has no idea whether they are seeing live data or a stale snapshot. This guide is the client-side counterpart to Backend Stream Generation & Connection Management: it covers how to consume text/event-stream correctly, wire it into React and Vue, synchronize it with application state, survive disconnects, and degrade gracefully when EventSource itself is unavailable. The audience is full-stack and frontend engineers who already understand HTTP and are now shipping real-time UI.
Concept Overview Permalink to this section
The browser ships a native client for SSE: the EventSource interface. When you construct one, the browser issues a GET request with Accept: text/event-stream, holds the response open, and incrementally parses the body as a UTF-8 byte stream broken into lines. Each event block β terminated by a blank line β is decoded into a MessageEvent and dispatched to the appropriate listener. The browser also manages reconnection for you: if the connection drops, it waits the reconnection interval and reopens automatically, sending the last received id back in a Last-Event-ID request header so the server can resume. Understanding the event stream format is a prerequisite, because the field grammar dictates which client callback fires.
Here is a minimal but production-shaped consumer with the three core lifecycle handlers:
// Construct with credentials so cookies ride along on cross-origin streams.
const es = new EventSource("/api/stream", { withCredentials: true });
// Fires once the HTTP response headers arrive and the stream is live.
es.onopen = () => console.info("[sse] open, readyState =", es.readyState); // 1 = OPEN
// Unnamed events (no `event:` field) dispatch to onmessage / "message".
es.onmessage = (e) => {
const payload = JSON.parse(e.data); // data is always a string; you decode it
applyUpdate(payload);
// e.lastEventId reflects the most recent `id:` the server sent.
};
// Named events: server sent `event: price\ndata: ...`
es.addEventListener("price", (e) => updateTicker(JSON.parse(e.data)));
// onerror fires on network drop, HTTP non-2xx, or parse abort.
// The browser auto-reconnects unless readyState === 2 (CLOSED).
es.onerror = () => {
if (es.readyState === EventSource.CLOSED) console.warn("[sse] closed permanently");
else console.warn("[sse] transient error, browser will retry");
};
// Always tear down explicitly to free the file descriptor and socket.
window.addEventListener("pagehide", () => es.close());
The critical mental model: EventSource is a receive-only HTTP connection with built-in retry. It cannot send a body, cannot set arbitrary request headers (no custom Authorization), and only speaks GET. Those constraints drive most of the architecture and authentication decisions later in this guide and shape the decision between SSE and WebSockets.
API Surface & Wire Mapping Permalink to this section
The EventSource object exposes a small, stable surface. Knowing exactly which wire field maps to which client property removes most parsing confusion. The table below is the authoritative mapping per the WHATWG HTML specification.
| Wire field / state | Client property or event | Type / values | Notes |
|---|---|---|---|
data: |
event.data |
string | Multiple data: lines join with \n; trailing newline stripped |
event: |
event type (listener name) | string | Absent β dispatched as message |
id: |
event.lastEventId |
string | Persisted; resent as Last-Event-ID on reconnect |
retry: |
(internal) | integer ms | Sets the reconnection delay; not exposed as a property |
: (comment) |
β | β | Ignored; used for heartbeat keep-alive |
| β | es.readyState |
0 CONNECTING, 1 OPEN, 2 CLOSED |
Read-only |
| β | es.url |
string | Resolved absolute URL |
| β | es.withCredentials |
boolean | Set only via constructor options |
| β | open event |
Event |
Fires on each successful (re)connection |
| β | error event |
Event |
No status code exposed; opaque by design |
| β | message event |
MessageEvent |
Default type for unnamed events |
Two facts trip up most teams. First, event.data is always a string β EventSource does not parse JSON; you call JSON.parse yourself and must guard against partial or malformed payloads. Second, the error event carries no diagnostic detail β you cannot read the HTTP status from it. To distinguish βserver returned 401β from βnetwork blip,β you either inspect readyState (a CLOSED state after a non-2xx means the browser gave up) or you abandon EventSource for a fetch-based reader, covered under fallback paths. For the precise reconnection-timing semantics, see Event ID & Retry Mechanism Design.
Architecture Patterns Permalink to this section
Client code rarely talks directly to the origin. Between the browser and your stream generator sit reverse proxies, CDNs, and load balancers β each of which can buffer, compress, or kill a long-lived response. The client patterns you choose must account for that path.
Reverse proxy in front of the consumer Permalink to this section
The single most common production failure is a proxy that buffers the response and never flushes it to the browser, so onopen fires but no events arrive. The client cannot fix this; the proxy must be configured to disable buffering. With nginx:
location /api/stream {
proxy_pass http://sse_upstream;
proxy_http_version 1.1; # SSE requires HTTP/1.1 chunked
proxy_set_header Connection ""; # clear "close", allow keep-alive
proxy_buffering off; # critical: stream, do not accumulate
proxy_cache off;
proxy_read_timeout 1h; # outlive idle gaps between events
chunked_transfer_encoding on;
add_header X-Accel-Buffering no; # belt-and-suspenders
}
From the client side, you defend against an undetectable buffering proxy by emitting a server heartbeat comment (: ping\n\n) every 15-30 seconds and tracking the time since the last received byte. If the gap exceeds a threshold, treat the connection as dead and force a reconnect even though the browser still believes it is OPEN.
Load balancer and sticky sessions Permalink to this section
When streams are stateful (the node holds an in-memory subscription), the load balancer must pin a client to the node that owns its connection. With cookie-based affinity the client is transparent; with header-based affinity you may need the server to echo a routing token. Stateless designs instead fan out events through a shared bus β see Redis Pub/Sub Fan-Out for SSE β so any node can serve any client, which removes sticky-session fragility entirely and is the pattern to prefer for new builds.
Framework hook as the integration boundary Permalink to this section
In a component-driven app, the EventSource lifecycle must be owned by a single hook or composable, not scattered across components. This is the boundary where you centralize connection setup, JSON decoding, reconnection policy, and teardown. The dedicated guides β React EventSource Hooks & State and Vue EventSource Composables β go deep, but the shape is consistent:
// A framework-agnostic core the hook wraps. Returns a teardown function.
type StreamHandlers = { onData: (d: unknown) => void; onStatus: (s: string) => void };
function connectStream(url: string, h: StreamHandlers): () => void {
let es: EventSource | null = new EventSource(url, { withCredentials: true });
h.onStatus("connecting");
es.onopen = () => h.onStatus("open");
es.onmessage = (e) => {
try { h.onData(JSON.parse(e.data)); }
catch { /* drop malformed frame, keep the stream alive */ }
};
es.onerror = () => h.onStatus(es?.readyState === 2 ? "closed" : "reconnecting");
return () => { es?.close(); es = null; }; // idempotent teardown
}
The hook calls connectStream on mount and the returned teardown on unmount. Getting that teardown right is the difference between a stable app and one that accumulates zombie sockets β the subject of the leak-prevention section below.
Edge Cases & Failure Modes Permalink to this section
Real streams fail in ways the happy path never shows. Each item below is a concrete scenario with its root cause and the client-side mitigation.
- Silent proxy buffering.
onopenfires butonmessagenever does. Root cause: an intermediary accumulates the chunked body. Mitigation: server sends periodic comment heartbeats; client runs a watchdog timer that forces a reconnect after N seconds of silence. - Reconnect storm after server outage. The server goes down; every client retries on the default ~3s interval and stampedes the moment it returns. Mitigation: have the server emit a
retry:field and apply client-side jitter, or layer your own exponential backoff on afetchreader. See Error Handling & Reconnection UX. - Connection cap exhaustion (HTTP/1.1). Browsers allow only ~6 concurrent connections per origin over HTTP/1.1; six open tabs each holding a stream exhaust the pool and block all other requests to that origin. Mitigation: serve SSE over HTTP/2 (multiplexed, ~100 streams) or share one
EventSourceacross tabs via aBroadcastChannel+ leader election. - Frozen background tab. Mobile and desktop browsers throttle or suspend timers and may freeze the connection in a hidden tab, so events queue and arrive in a burst on resume β or the socket is reaped. Mitigation: pause and resume deliberately using the Page Visibility API; see Mobile & Background-Tab Handling.
- Stale state after reconnect gap. The browser resumes with
Last-Event-ID, but if the server cannot replay missed events the UI shows a gap. Mitigation: onopenafter a prior error, refetch a snapshot, then resume the live stream β a βsnapshot + deltaβ pattern coordinated through your store, covered in State-Management Integration for SSE. - Malformed JSON crashes the handler. One bad frame throws inside
onmessageand, if unguarded, can leave your reducer in a partial state. Mitigation: wrap everyJSON.parsein try/catch and drop the frame rather than the stream. - Auth token expiry mid-stream.
EventSourcecannot set anAuthorizationheader, so cookie- or query-token auth that expires causes a 401 on the next reconnect that surfaces only as an opaqueerror. Mitigation: refresh the token before expiry and reconnect proactively; see Authenticating SSE Streams with Tokens & Cookies.
React-specific leak: re-creating the source on every render Permalink to this section
The canonical React bug is constructing EventSource in the component body or in an effect with an unstable dependency, opening a new socket on each render and never closing the old one:
// WRONG: new connection every render, old ones leak.
function Ticker() {
const es = new EventSource("/api/stream"); // runs on every render
// ...
}
// RIGHT: one connection per mount, explicit cleanup.
useEffect(() => {
const es = new EventSource("/api/stream", { withCredentials: true });
es.onmessage = (e) => setData(JSON.parse(e.data));
return () => es.close(); // teardown on unmount / dep change
}, []); // empty deps: open once
Detailed leak diagnosis and Strict-Mode double-invocation handling live in Preventing EventSource Memory Leaks in React.
Horizontal Scaling & Production Ops Permalink to this section
Each open EventSource is one TCP connection and, on the server, one file descriptor held for the connectionβs lifetime β minutes to hours. The client side influences server load directly, so frontend decisions are capacity decisions.
- Connection budgeting. Multiply expected concurrent users by streams-per-user. Ten thousand users with one stream each is ten thousand persistent sockets per node β well beyond default OS limits. The server team must raise descriptor limits accordingly; see Tuning File-Descriptor Limits for SSE Connection Pools.
- HTTP/2 multiplexing. Serving streams over HTTP/2 collapses many SSE streams onto one TCP connection, sidestepping the 6-per-origin HTTP/1.1 cap and reducing socket pressure. This is the highest-leverage change for connection-heavy frontends.
- Coalesce streams. Prefer one multiplexed
EventSourcecarrying severalevent:channels over several separateEventSourceobjects. One connection, many named event types, is cheaper on both ends. - Client-side observability. Emit metrics from the client: connection state transitions, reconnect count, time-to-first-event, and silence-watchdog firings. These reveal proxy buffering and reconnect storms that server logs alone miss. Surface the live state to users via a connection-status indicator.
A compact instrumentation wrapper:
function instrumentedSource(url: string) {
let opened = 0, reconnects = 0;
const t0 = performance.now();
const es = new EventSource(url, { withCredentials: true });
es.addEventListener("open", () => {
opened += 1;
if (opened > 1) reconnects += 1;
metrics.timing("sse.ttfo_ms", performance.now() - t0); // time to (re)open
metrics.gauge("sse.reconnects", reconnects);
});
es.addEventListener("error", () => metrics.increment("sse.error"));
return es;
}
Migration & Fallback Paths Permalink to this section
Most teams reach SSE from an existing real-time approach, and not every client environment can use EventSource. Plan both directions.
From polling Permalink to this section
If you currently poll a GET endpoint on an interval, SSE is a drop-in latency win: the endpoint already returns JSON, so wrap a generator that emits each update as a data: frame and replace the polling loop with an EventSource. Keep the polling endpoint as a fallback (below). For the trade-off analysis, see SSE vs WebSockets vs HTTP Polling.
From WebSockets Permalink to this section
If you adopted WebSockets only for server-to-client push and never use the client-to-server channel, SSE is simpler, cheaper, and auto-reconnecting. Migration means replacing the onmessage socket handler with an EventSource of the same shape and moving any client-originated messages to ordinary fetch calls. Keep WebSockets only where you genuinely need bidirectional, low-latency, binary traffic.
fetch + ReadableStream fallback Permalink to this section
When you need request headers (a Bearer token), non-GET methods, or richer error inspection than EventSource allows, parse the stream yourself with fetch and a ReadableStream reader. This also covers polyfill scenarios; see Browser Support & Polyfill Strategies.
async function streamWithFetch(url: string, token: string, onData: (d: unknown) => void) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
});
if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`); // real status!
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let idx;
// Split on the blank-line event delimiter (handle \n\n and \r\n\r\n).
while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
const block = buf.slice(0, idx);
buf = buf.slice(idx + buf.match(/\r?\n\r?\n/)![0].length);
const data = block.split(/\r?\n/)
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).replace(/^ /, "")) // strip one optional leading space
.join("\n");
if (data) { try { onData(JSON.parse(data)); } catch { /* skip */ } }
}
}
}
This gives you the real HTTP status, custom headers, and full control over backoff β at the cost of reimplementing the parsing and reconnection that EventSource gives for free. Use it as a fallback or where its capabilities are required, not as the default.
β‘ Production Directives
- Own the entire `EventSource` lifecycle in one hook/composable and always close on unmount β never construct a source in a component body.
- Run a silence watchdog: force-reconnect if no byte arrives within ~2Γ the server heartbeat interval, regardless of `readyState`.
- Serve streams over HTTP/2 and coalesce channels into one connection to escape the 6-connection HTTP/1.1 per-origin cap.
- Guard every `JSON.parse(e.data)` in try/catch; drop bad frames, never the stream.
- On reconnect, refetch a snapshot then resume live to close any replay gap, and surface connection state to the user.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why does onmessage never fire even though onopen did?
Almost always a proxy or CDN buffering the chunked response instead of flushing it. The connection opens, but no event bytes reach the browser. Disable buffering on every intermediary (`proxy_buffering off`, `X-Accel-Buffering: no`) and add a server heartbeat plus a client silence watchdog to detect the condition.
Can I send an Authorization header with EventSource?
No. The native EventSource API does not allow custom request headers. Authenticate via same-site cookies (with `withCredentials: true`) or a token in the query string, or switch to a fetch + ReadableStream reader when you need a Bearer header.
How do I detect a 401 versus a network drop?
The EventSource `error` event is opaque and exposes no status code. Inspect `readyState`: a permanent `CLOSED` (2) after a non-2xx means the browser stopped retrying. For real status codes, read the stream with fetch instead, where `res.status` is available.
Should I open one EventSource per data type?
No. Prefer a single connection that multiplexes several named `event:` types. Multiple sources waste sockets and, over HTTP/1.1, quickly hit the ~6-per-origin connection cap. One stream, many event names, is cheaper on client and server.
What happens to the stream when the tab goes to the background?
Browsers throttle or freeze timers and may suspend the connection; events queue and burst on resume, or the socket is reaped. Use the Page Visibility API to pause on `hidden` and reconnect on `visible`, refetching a snapshot to close any gap.