Fetch-Based SSE Clients Permalink to this section

Part of Frontend Consumption & Client Patterns.

EventSource is the easiest way to consume Server-Sent Events, and for many applications it is all that is needed. Its API is also deliberately small: it only makes GET requests, it cannot set request headers such as Authorization, it hides the HTTP status code of a failed connection, and its reconnection policy is fixed. As soon as an application needs a bearer token, a request body, a precise error message or a custom backoff, the answer is to read the stream with fetch and parse the event stream format yourself. The format is simple enough that this is a few dozen lines — but those lines have to get chunk boundaries, line endings, UTF-8, reconnection and Last-Event-ID exactly right, or the client will mostly work and occasionally corrupt data. This guide covers the whole client: the parsing algorithm, a reconnecting wrapper, cancellation, and the edge cases that distinguish a robust implementation from a demo. It is for frontend engineers building AI chat interfaces, authenticated dashboards and anything else where EventSource’s limits bite.

How It Works Permalink to this section

A fetch-based client does four things: issue the request with whatever method, headers and body it needs; read the response body as a stream of bytes; decode and split those bytes into lines and events according to the specification; and, when the stream ends or fails, decide whether and when to reconnect.

The pieces of a fetch-based SSE client Flow from a fetch request with headers and body, through a ReadableStream of bytes, a streaming UTF-8 decoder, a line splitter and event assembler, to dispatch, with a reconnect loop around the whole pipeline. The pieces of a fetch-based SSE client fetch() method, headers, body response.body ReadableStream bytes, any chunking decode TextDecoder UTF-8, streaming lines Line + event parser per spec events Dispatch onEvent(type, data, id) A reconnect loop around the whole pipeline replaces EventSource's built-in retry.
EventSource does all five steps internally. A fetch client does them in your code, which is exactly what makes headers, bodies and status codes available.

The comparison with EventSource is worth making explicit, because every capability gained is a responsibility taken on:

Capability EventSource fetch-based client
Request method and body GET only any method, JSON body
Request headers none (cookies only) any, including Authorization
HTTP status on failure hidden available
Parsing built in your code (or a library)
Reconnect and Last-Event-ID automatic your code
Cancellation close() AbortController
Available in Web Workers yes yes

The parser is the part to get right first. Parsing SSE from a fetch ReadableStream walks through it line by line; the essential rules are that bytes must be decoded with a streaming decoder, lines end at CRLF, CR or LF (and a CRLF may be split across chunks), a blank line dispatches, and a line starting with a colon is a comment.

Server-Side Implementation Permalink to this section

The server is an ordinary SSE endpoint — nothing about it needs to change for fetch clients. Two server behaviours matter more than usual, though, because fetch clients expose them:

  • Status codes become meaningful. A fetch client can distinguish 401 (refresh the token), 403 (stop), 429 (back off for a while) and 5xx (retry soon). Return accurate statuses before streaming starts.
  • POST streams are common. AI chat and search endpoints accept a JSON body and respond with a stream. Such endpoints should still assign ids and support resumption where it makes sense — for example by returning a generation id in the first event, so a reconnect can be a GET for that generation from the last id.
// A POST endpoint that streams, with a resumable id per generation.
app.post('/api/chat', requireBearer, express.json(), async (req, res) => {
  const gen = await generations.create(req.user.id, req.body);
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
  res.write(`event: meta\ndata: ${JSON.stringify({ generationId: gen.id })}\n\n`);
  let seq = 0;
  for await (const token of gen.stream()) {
    res.write(`id: ${gen.id}:${++seq}\nevent: token\ndata: ${JSON.stringify({ t: token })}\n\n`);
  }
  res.end('event: done\ndata: {}\n\n');
});

// Resume: GET the same generation from a given position.
app.get('/api/chat/:gid/stream', requireBearer, (req, res) => streamFrom(req.params.gid, req.get('Last-Event-ID'), res));

Sending POST requests that return SSE builds the full pattern, including idempotency so a retried POST does not start a second generation.

Client-Side Consumption Permalink to this section

A complete client wraps the parser in a loop that handles status codes, reconnection with backoff, Last-Event-ID and cancellation:

// fetch-sse.js — a reconnecting fetch-based SSE client.
export function fetchEventSource(url, { method = 'GET', headers = {}, body, onEvent, onOpen, onError, signal,
                                        retryMs = 3000, maxRetryMs = 30000 } = {}) {
  let lastId = '';
  let delay = retryMs;
  const outer = new AbortController();
  signal?.addEventListener('abort', () => outer.abort());

  (async function run() {
    while (!outer.signal.aborted) {
      try {
        const res = await fetch(url, {
          method, body, signal: outer.signal,
          headers: { Accept: 'text/event-stream', ...(lastId && { 'Last-Event-ID': lastId }), ...(await resolve(headers)) },
        });
        if (res.status === 401 || res.status === 403) throw Object.assign(new Error('auth'), { fatal: res.status === 403, status: res.status });
        if (!res.ok || !res.headers.get('content-type')?.startsWith('text/event-stream')) {
          throw Object.assign(new Error(`bad response ${res.status}`), { status: res.status });
        }
        onOpen?.(res);
        delay = retryMs;                                       // healthy connection: reset backoff
        await parseStream(res.body, (evt) => {
          if (evt.id !== undefined) lastId = evt.id;
          if (evt.retry !== undefined) retryMs = evt.retry;
          onEvent(evt);
        });
      } catch (err) {
        if (outer.signal.aborted) return;
        if (err.fatal) { onError?.(err); return; }             // do not retry
        onError?.(err);
      }
      await sleep(delay + Math.random() * delay * 0.3, outer.signal);   // jittered backoff
      delay = Math.min(delay * 2, maxRetryMs);
    }
  })();

  return () => outer.abort();
}

headers may be a function, so each attempt can fetch a fresh access token — the pattern covered in adding auth headers to SSE requests. Returning an abort function makes the client easy to tie to a component’s lifecycle, as in the React EventSource hooks topic.

Libraries exist for all of this — Microsoft’s @microsoft/fetch-event-source is widely used, and eventsource-parser provides a spec-compliant parser to build on. Using one is often the right call; understanding what it does is still necessary to configure it correctly. With @microsoft/fetch-event-source, the key decisions live in its callbacks: onopen must validate the status and content type and throw for anything unexpected, onerror decides whether to retry (return nothing or a delay) or stop (throw), and openWhenHidden controls whether the stream is closed while the tab is hidden:

import { fetchEventSource } from '@microsoft/fetch-event-source';

class FatalError extends Error {}

await fetchEventSource('/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await getToken()}` },
  body: JSON.stringify({ prompt }),
  signal: controller.signal,
  openWhenHidden: true,                          // keep an in-flight generation alive in background tabs
  async onopen(res) {
    if (res.ok && res.headers.get('content-type')?.startsWith('text/event-stream')) return;
    if (res.status >= 400 && res.status < 500 && res.status !== 429) throw new FatalError(res.statusText);
    throw new Error(`retryable ${res.status}`);
  },
  onmessage(ev) { if (ev.event === 'token') append(JSON.parse(ev.data).t); },
  onerror(err) { if (err instanceof FatalError) throw err; /* else retry with the library's backoff */ },
});

If you only need parsing — because reconnection is handled elsewhere, or the stream is a one-shot POST — eventsource-parser is smaller and does exactly the spec’s algorithm:

import { createParser } from 'eventsource-parser';

const parser = createParser({ onEvent: (e) => handle(e.event, e.data, e.id) });
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  parser.feed(value);
}

For a POST stream that should survive a network drop, the resumption usually switches method: the POST starts the work and returns an id in its first event, and every reconnect is a GET for that id from the last event id. The client code above handles it by changing url, method and body after the first meta event:

A POST stream resumed with GET after a drop Sequence diagram of a browser posting a prompt, receiving a generation id and tokens, losing the connection, and resuming with a GET for the generation from the last event id. A POST stream resumed with GET after a drop Browser API POST /api/chat {prompt} meta generationId g_91 token id g_91:40 connection lost GET /api/chat/g_91/stream Last-Event-ID g_91:40 tokens from g_91:41, then done
The POST is sent once. Everything after it is an idempotent GET, which is safe to retry any number of times.
Connection states in a fetch-based client State diagram of a fetch-based SSE client moving between connecting, open, backing off and closed, with transitions on response, stream end, errors and fatal statuses. Connection states in a fetch-based client CONNECTING fetch in flight OPEN reading body BACKOFF jittered delay CLOSED aborted or fatal 200 event-stream stream ended / error timer, Last-Event-ID 403 / abort
The state machine is the same one EventSource runs internally. Writing it yourself adds one important transition: fatal statuses go straight to closed.

Edge Cases & Network Interference Permalink to this section

The places where hand-written clients most often go wrong:

  • Split multi-byte characters. Decoding each chunk separately corrupts characters split across chunks. Use one TextDecoder with { stream: true } or TextDecoderStream.
  • Split line endings. A CRLF can arrive as CR at the end of one chunk and LF at the start of the next. A parser that treats CR as a line end must then ignore the immediately following LF.
  • Events without a trailing blank line at stream end. The specification discards an incomplete event when the stream ends; do not dispatch it.
  • Page lifecycle. A fetch stream in a backgrounded mobile tab is suspended like any network activity. Handle visibilitychange and reconnect on return rather than waiting for a timeout.
  • Service workers. A service worker that intercepts and re-fetches requests may buffer the response if it reads the body before responding. Exclude stream URLs from service worker fetch handling.
  • Proxy buffering. Intermediaries that buffer responses affect fetch streams exactly as they affect EventSource. A fetch client does not help against them; a first-event timeout detects them, as in detecting EventSource support and falling back.

One more difference from EventSource is easy to overlook: cookies and CORS. fetch sends cookies to other origins only with credentials: 'include', and a cross-origin request with an Authorization header or a JSON body triggers a CORS preflight. The server must answer the OPTIONS preflight, allow the headers you send (Authorization, Last-Event-ID, Content-Type), and — if credentials are included — name the exact origin rather than *. The same rules apply to EventSource with withCredentials, but fetch clients hit them more often because they send more headers; see handling CORS in SSE implementations.

Mitigation checklist:

Performance & Scale Considerations Permalink to this section

A fetch-based client runs its parser in JavaScript on whichever thread owns the fetch. For typical event rates the cost is negligible. At high rates — token streams from language models at hundreds of tokens per second, or market data — three optimisations keep the main thread free:

Main-thread time per second at 500 events per second Bar chart comparing main-thread CPU time for EventSource, a naive fetch parser using string concatenation and split, an optimised incremental parser, and parsing in a Web Worker. Main-thread time per second at 500 events per second EventSource (native) 38 ms Naive fetch parser 140 ms Incremental parser 42 ms Parser in a Web Worker 6 ms milliseconds of main-thread work per second (mid-range laptop)
A careful parser is as cheap as the native one. Moving parsing into a worker takes it off the main thread entirely.
  1. Parse incrementally. Keep an index into the buffer instead of re-splitting the whole accumulated string on every chunk; the naive approach is quadratic in the size of large events.
  2. Batch dispatch to animation frames. Collect events in an array and hand them to the UI once per frame, as in throttling dashboard updates to the frame rate.
  3. Run in a worker. fetch and ReadableStream are available in Web Workers; parse there and post batches to the page.

Running the client in a worker takes a few lines, because the parser and fetch are identical there. The worker posts batches; the page applies them once per frame:

// sse.worker.js
import { fetchEventSource } from './fetch-sse.js';
let batch = [];
self.onmessage = ({ data: { url, token } }) => {
  fetchEventSource(url, { headers: () => ({ Authorization: `Bearer ${token}` }), onEvent: (e) => batch.push(e) });
  setInterval(() => { if (batch.length) { self.postMessage(batch); batch = []; } }, 16);
};

// page
const w = new Worker(new URL('./sse.worker.js', import.meta.url), { type: 'module' });
w.onmessage = ({ data }) => requestAnimationFrame(() => data.forEach(applyEvent));
w.postMessage({ url: '/api/stream', token });

Token refresh then happens in the page and is posted to the worker, which uses the new token on its next reconnect.

Connection limits are the same as for EventSource: on HTTP/1.1, six connections per origin, shared with every other request. Prefer HTTP/2, and share one stream between tabs where possible.

Validation & Debugging Permalink to this section

The parser deserves unit tests that feed the same stream split at every byte offset and assert identical output — the single most effective test for this kind of code. For the client as a whole:

# Server side: confirm POST streaming and status codes independently of the client.
curl -sN -X POST -H 'Authorization: Bearer $T' -H 'Content-Type: application/json' \
  -d '{"prompt":"hello"}' https://app.example.com/api/chat
curl -s -o /dev/null -w '%{http_code}\n' https://app.example.com/api/chat          # expect 401

In DevTools, fetch streams appear in the Network panel as ordinary requests; the EventStream tab is only shown for EventSource connections, so log parsed events in development builds, or expose a debug hook that records the last N events with timestamps. Record reconnect attempts with the status or error that caused each one, so telemetry distinguishes auth expiries from network failures.

onError: (err) => telemetry.count('sse_reconnect', { status: err.status ?? 'network' })

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

When should I use fetch instead of EventSource?

When you need request headers such as Authorization, a POST body, the HTTP status of failures, or control over reconnection. Otherwise EventSource is simpler and handles reconnection and resume for you.

Is @microsoft/fetch-event-source still a good choice?

It implements the parsing and reconnection described here and is widely used. Configure its retry and error callbacks deliberately, and check it handles your fatal statuses the way you intend.

Can a fetch-based client resume with Last-Event-ID?

Yes, by tracking the last id from parsed events and sending it as a header on the next request. That is one of the main things the client must implement itself.

Does fetch streaming work in all browsers?

Streaming response bodies via response.body are supported in all current browsers. Streaming request bodies are a separate, less widely supported feature that SSE clients do not need.

Can I use a fetch-based client in React Native or Node.js?

In Node.js 18 and later, fetch returns a web ReadableStream and the same code works. React Native's fetch does not stream response bodies by default, so use a native SSE library or an XHR-based parser there.

How do I type events in TypeScript?

Define a discriminated union keyed by event name, parse data with a schema validator such as Zod or a generated decoder, and dispatch to typed handlers. Validation at the boundary catches server changes before they reach the interface.

Is a fetch-based client more reliable than EventSource on bad networks?

Not inherently. It gives more control and better diagnostics, but it is subject to the same proxies and timeouts, and it is only as reliable as its reconnect logic.

Deep Dives