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.

Browser SSE consumption pipeline Data flows from the server stream through the EventSource parser into a framework hook, then into state and the UI, with a reconnection loop carrying Last-Event-ID. SSE Server text/event-stream chunked HTTP EventSource UTF-8 line parse field dispatch Framework Hook useEventSource lifecycle + cleanup State + UI store / signal render Reconnection loop onerror β†’ wait retry ms β†’ reopen resume with Last-Event-ID header visibility-aware pause on hidden tab readyState connecting open error/closed
How an SSE stream flows from server to UI, with the reconnection loop that keeps it alive.

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.

Which wire field reaches which part of the client API The parsed event stream fans out to four client surfaces: the event field selects the listener, data becomes the message payload, id becomes lastEventId and the reconnect header, and retry silently reconfigures the browser timer. Which wire field reaches which part of the client API Parsed event block one blank-line-terminated frame event: price addEventListener("price") data: … e.data (string, joined by \n) id: 4821 e.lastEventId β†’ Last-Event-ID retry: 5000 browser reconnect timer only
Only three of the four fields ever reach your code β€” retry is consumed by the browser itself, which is why a mis-set retry interval is invisible in application logs.
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.

onopen fired but no events β€” where to look Decision tree for a stream that opens but never dispatches: check whether raw bytes reach curl, then whether the listener name matches the event field. onopen fired but no events β€” where to look Does curl -N show frames? Server or proxy buffering, gzip, no flush no frames Client listener named event vs onmessage frames arrive
Split the problem at the network boundary first: curl proves whether the bytes exist, which decides whether you are debugging infrastructure or client code.
  • Silent proxy buffering. onopen fires but onmessage never 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 a fetch reader. 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 EventSource across tabs via a BroadcastChannel + 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: on open after 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 onmessage and, if unguarded, can leave your reducer in a partial state. Mitigation: wrap every JSON.parse in try/catch and drop the frame rather than the stream.
  • Auth token expiry mid-stream. EventSource cannot set an Authorization header, so cookie- or query-token auth that expires causes a 401 on the next reconnect that surfaces only as an opaque error. Mitigation: refresh the token before expiry and reconnect proactively; see Authenticating SSE Streams with Tokens & Cookies.
  • Unbounded event accumulation. A feed component that appends every incoming event to an array holds every event the session ever received. After an hour on a busy stream that is tens of thousands of objects, each retained by the array and by any component that rendered it. Mitigation: cap the collection at the number of items the UI can actually show β€” a ring buffer of 200 to 500 entries β€” and discard from the head as new events arrive.
  • Duplicate handlers after a hot reload. During development, a module that registers addEventListener('message', …) at module scope accumulates one handler per hot reload, so a single event fires the handler five or ten times and the UI appears to receive duplicates that never existed on the wire. Mitigation: register listeners inside the component lifecycle, never at module scope, and confirm the count with getEventListeners(es) in the console before blaming the server.
  • Clock-based UI drift. Components that render β€œupdated 3 seconds ago” from a client clock disagree with an event timestamp generated on the server, and the discrepancy grows with clock skew. Mitigation: render relative times from the server-supplied timestamp and treat the client clock as a rendering detail only, never as an ordering key.
  • Events arriving before the component is ready. A stream opened in a top-level layout starts dispatching before the view that consumes the data has mounted, so the first events land with no subscriber and vanish. Mitigation: buffer into the store rather than into component state β€” the store exists before any component does, which is the same argument that puts the connection in middleware.

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.

How one user becomes server sockets Budget stack showing how open tabs multiply into EventSource instances, TCP connections and finally server file descriptors. How one user becomes server sockets 1 user, 3 tabs same origin Γ— tabs open 2 EventSource per tab feed + notifications = 6 logical streams 6 TCP connections HTTP/1.1: 6-per-origin cap hit HTTP/2 multiplexes to 1 6 server descriptors held for the session Γ— every concurrent user multiplier at this level
Frontend decisions are capacity decisions: two streams in three tabs is six sockets per user, and a SharedWorker collapses them back to one.
  • 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 EventSource carrying several event: channels over several separate EventSource objects. 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.

Client capability ladder Fallback ladder: native EventSource where available, a fetch and ReadableStream reader when custom headers are required, and interval polling as the last resort. Client capability ladder same text/event-stream bytes at every rung EventSource native, auto-retry fetch + reader custom headers, POST Interval polling last resort needs Authorization? no streaming at all?
All three rungs consume the same wire format, so the server never changes β€” only the client parser does.

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.

Testing & Instrumenting Client Stream Code Permalink to this section

Stream consumers are hard to test for one reason: EventSource is a browser global that opens a real network connection the moment it is constructed. Tests that do not replace it either hang waiting for a server or pass for the wrong reason. The fix is a small fake that records what the code under test did and lets the test drive the events.

Swapping the global for a fake you can drive Test harness structure: the fake EventSource replaces the browser global, the component under test constructs it unknowingly, and the test drives events and asserts on the recorded instances. Swapping the global for a fake you can drive the fake records every instance so leaks become a length check Test installs the fake globalThis EventSource = Fake Component constructs as usual Assertions instances Β· closed Β· render before each unchanged code emitMessage()
The component never knows it is being tested β€” which is what makes the connection count and the close() call meaningful assertions rather than mocked-out behaviour.
// test/fake-event-source.js
export class FakeEventSource {
  static instances = [];

  constructor(url, init = {}) {
    this.url = url;
    this.withCredentials = !!init.withCredentials;
    this.readyState = 0;                 // CONNECTING
    this.listeners = new Map();
    this.closed = false;
    FakeEventSource.instances.push(this);
  }

  addEventListener(type, fn) {
    if (!this.listeners.has(type)) this.listeners.set(type, new Set());
    this.listeners.get(type).add(fn);
  }

  removeEventListener(type, fn) {
    this.listeners.get(type)?.delete(fn);
  }

  close() {
    this.closed = true;
    this.readyState = 2;                 // CLOSED
  }

  // ── test-side controls ────────────────────────────────────────────────
  emitOpen() {
    this.readyState = 1;
    this.dispatch('open', {});
  }

  emitMessage(data, { id, type = 'message' } = {}) {
    this.dispatch(type, { data: JSON.stringify(data), lastEventId: id ?? '' });
  }

  emitError() {
    this.readyState = 0;                 // the browser retries: back to CONNECTING
    this.dispatch('error', {});
  }

  dispatch(type, event) {
    this.listeners.get(type)?.forEach((fn) => fn(event));
    const inline = this[`on${type}`];
    if (typeof inline === 'function') inline(event);
  }
}

With the fake installed as the global, the interesting assertions become ordinary synchronous checks β€” how many connections were opened, whether the last one was closed, and what the component rendered for a given sequence of frames:

// test/live-feed.test.js
import { FakeEventSource } from './fake-event-source';

beforeEach(() => {
  FakeEventSource.instances = [];
  globalThis.EventSource = FakeEventSource;
});

it('opens exactly one stream and closes it on teardown', () => {
  const view = mountLiveFeed('/api/events');

  expect(FakeEventSource.instances).toHaveLength(1);   // no duplicate connections
  const es = FakeEventSource.instances[0];

  es.emitOpen();
  es.emitMessage({ symbol: 'EUR', price: 1.08 }, { id: '4821' });
  expect(view.text()).toContain('1.08');

  view.unmount();
  expect(es.closed).toBe(true);                        // the leak test
});

it('survives a malformed frame without tearing down the stream', () => {
  mountLiveFeed('/api/events');
  const es = FakeEventSource.instances[0];
  es.emitOpen();

  es.dispatch('message', { data: '{not json' });       // must be swallowed
  es.emitMessage({ symbol: 'EUR', price: 1.09 });

  expect(es.closed).toBe(false);
});

Three assertions carry most of the value: the instance count catches unstable dependencies that reopen the stream, the closed flag catches the leak, and driving a malformed frame catches the unguarded JSON.parse that would otherwise take down the handler in production.

Instrumentation follows the same shape. The metrics worth emitting from the client are the ones that distinguish a healthy stream from one that is quietly failing, and none of them are available server-side:

Metric Emitted when What a bad value means
sse_time_to_first_event_ms first frame after open proxy buffering or a slow producer
sse_reconnect_count every error β†’ open cycle unstable path or an aggressive idle timeout
sse_silence_watchdog_fired watchdog forces a reconnect heartbeats stopped while the socket stayed open
sse_parse_failures JSON.parse throws producer emitting frames the client cannot read
sse_replay_gap_detected id sequence skips replay window too small for real disconnect times

Send these as ordinary analytics events, sampled. The pair that matters most is time-to-first-event and watchdog firings: together they separate β€œthe stream never started” from β€œthe stream started and then went silent”, which are different bugs in different layers and are otherwise indistinguishable from the user’s side of the connection.

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.

Topics in this Section