Safeguard
Open Source

@microsoft/fetch-event-source: Robust SSE Streams in the Browser

The fetch event source library fixes everything the native EventSource API refuses to do: POST bodies, auth headers, and retry logic you actually control.

Marcus Chen
DevSecOps Engineer
7 min read

The fetch event source pattern, implemented by the @microsoft/fetch-event-source package, gives you server-sent events over the Fetch API, which means POST bodies, authorization headers, response validation, and retry logic the native EventSource simply does not allow. If you have built an LLM chat interface, a build-log tail, or a live notifications feed lately, you have probably hit the wall this library exists to remove: the browser's built-in EventSource only speaks GET, sends no custom headers, and retries on its own schedule whether you like it or not.

This guide covers what the library does, the API in working code, the reconnection semantics you must handle yourself, and the one honest caveat, its frozen release history, that should shape how you adopt it.

Why native EventSource is not enough

Server-sent events are the simplest streaming primitive on the web: a long-lived HTTP response with Content-Type: text/event-stream, emitting data: lines. The native API is seductively short:

const es = new EventSource('/api/stream');
es.onmessage = (ev) => console.log(ev.data);

And then reality arrives:

  • No headers. You cannot send Authorization: Bearer ..., so authentication degrades to cookies or, worse, tokens in the query string, where they land in access logs and proxy caches.
  • GET only. Streaming the answer to a large prompt or filter object means stuffing it into the URL.
  • Opaque retries. The browser silently reconnects on error, even when the error is a 401 that will never succeed, hammering your backend while the user sees nothing.
  • No response inspection. You cannot check the status code or content type before committing to parse the stream.

For anything beyond a public ticker, these are disqualifying. This is precisely the gap the Microsoft fetch event source package fills.

The core API in one example

The library exports a single function, fetchEventSource, that accepts everything fetch accepts plus SSE lifecycle callbacks:

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

const ctrl = new AbortController();

await fetchEventSource('/api/chat/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ prompt, sessionId }),
  signal: ctrl.signal,

  async onopen(response) {
    if (response.ok && response.headers.get('content-type')?.includes('text/event-stream')) {
      return; // all good, start parsing
    }
    throw new Error(`Unexpected response: ${response.status}`);
  },
  onmessage(ev) {
    render(JSON.parse(ev.data));
  },
  onclose() {
    // server closed the stream cleanly
  },
  onerror(err) {
    // returning normally = retry; throwing = give up
    throw err;
  },
});

Three things native EventSource cannot do are happening here: a POST body, a bearer token in a real header, and an onopen gate that validates the response before a single event is parsed. The AbortController gives you clean teardown when the component unmounts.

Retry semantics: the part everyone gets wrong

The library's retry contract is simple and unforgiving: if onerror returns without throwing, the request retries; if it throws, the stream is dead. The idiomatic pattern from the project's own documentation distinguishes retriable from fatal failures:

class RetriableError extends Error {}
class FatalError extends Error {}

await fetchEventSource('/api/stream', {
  async onopen(response) {
    if (response.ok) return;
    if (response.status >= 400 && response.status < 500 && response.status !== 429) {
      throw new FatalError(); // 401, 403, 404: retrying is pointless
    }
    throw new RetriableError(); // 5xx, 429: worth another attempt
  },
  onerror(err) {
    if (err instanceof FatalError) throw err;
    // otherwise fall through and let it retry
  },
});

This is a genuine improvement over the native behavior, where a misconfigured endpoint gets retried forever, but note what the library does not give you: there is no built-in exponential backoff. onerror can return a retry delay in milliseconds, and computing that delay (with jitter, with a cap, with a give-up counter) is your job. Skipping it means every deploy that briefly 503s your SSE endpoint triggers a synchronized reconnect stampede from every open tab. Treat backoff as part of the endpoint's capacity planning, the same way you would rate-limit any other dynamically tested API surface.

The Page Visibility trick

A quietly excellent feature: the library integrates the Page Visibility API by default. When the user backgrounds the tab, the connection closes; when they return, it reconnects and replays from the Last-Event-ID header. For dashboards left open in a dozen tabs, this alone can cut a surprising amount of idle connection load on your servers.

If your stream must keep flowing in hidden tabs (audio transcription, alerting), opt out with openWhenHidden: true, and make sure the server honors Last-Event-ID so nothing is lost either way. If your backend does not implement event IDs and resume, the reconnect is silently lossy, which is a data-integrity bug users report as "the feed sometimes skips messages".

Maintenance status: adopt with eyes open

Now the caveat. The latest release, 2.0.1, was published in April 2021, and there has been no release since. The GitHub repository (now under the Azure org) is not archived and holds around 2.8k stars, but issues and PRs accumulate without releases. Snyk's database lists no known vulnerabilities for the package, and it has zero runtime dependencies, so there is nothing to rot transitively.

How much should this worry you? Score it the way you would score any dependency:

  • Small, finished surface. The SSE wire format has not changed, and the package does one thing. Like a completed protocol implementation, it can be stable without releases.
  • Zero dependencies, MIT license. The vendoring escape hatch is fully open; the whole library is a few files of readable TypeScript.
  • Known unfixed rough edges. Long-standing issues around edge cases (for example, error handling quirks and no maintained fix for them) will not be fixed upstream on any schedule. Community forks exist for teams that need them.

An SCA tool such as Safeguard will tell you the package is CVE-free, but flagging a five-year release gap is exactly the kind of maintenance signal worth wiring into your dependency review policy alongside the vulnerability data. CVE-free and actively-maintained are different properties; you want to know when you are trading one for the other, a distinction we dig into in the Safeguard Academy material on evaluating open source dependencies.

Alternatives and when to pick them

  • Native EventSource: still right for unauthenticated GET streams with default retry behavior. Less code is less code.
  • Hand-rolled fetch + ReadableStream parsing: what many LLM SDKs do internally. Full control, but you now own the SSE parser, including multi-line data: fields, comment lines, and CR/LF handling.
  • Community forks (for example @fixers/fetch-event-source and similar): pick one if you need post-2021 fixes, but vet the fork's maintainer with the same care as any new dependency.
  • WebSockets: the right answer when you need bidirectional traffic, and the wrong one when you only need server-to-client, since you give up plain-HTTP operability, proxy friendliness, and automatic resume.

For one-directional streams behind auth, which describes most product features built in the last two years, fetchEventSource remains the pragmatic default despite its frozen changelog.

FAQ

Is @microsoft/fetch-event-source still maintained?

Effectively no: the last release (2.0.1) shipped in April 2021, though the repository stays open under the Azure GitHub org. The package has no known vulnerabilities and no dependencies, so many teams run it as a stable, finished component and keep the option of vendoring or forking.

How is fetch event source different from EventSource?

fetchEventSource runs SSE over the Fetch API, so you get custom headers (including Authorization), POST bodies, response validation in onopen, controllable retries, and AbortController teardown. Native EventSource allows none of these.

Does fetchEventSource retry automatically?

Yes, unless you throw inside onerror. The library retries by default, and your onerror/onopen logic decides which failures are fatal (401, 403) versus retriable (5xx, 429). Exponential backoff is not built in; you implement the delay yourself.

Can I use fetch event source with LLM streaming APIs?

Yes, it is a common choice for chat UIs that stream model output over SSE with a POST body and bearer token. Ensure the server emits event IDs if you rely on the library's automatic resume after tab visibility changes.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.