# fetch-event-source: SSE With Bearer Tokens, Retries, and React

> Native EventSource cannot send an Authorization header or a POST body. Use @microsoft/fetch-event-source to consume SSE with bearer tokens, validate responses, control retries, and clean up connections in React. The server still owns event routing and replay.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/fetch-event-source

**fetch-event-source** is a JavaScript SSE client that uses Fetch, so you can send bearer headers or a POST body and decide which failures to retry.
Use it when native `EventSource` cannot express your request.
It handles stream parsing, but your server still needs to authorize subscriptions and implement replay if missed events matter.

The most common reason to reach for it is authentication.
Your API expects `Authorization: Bearer ...`, and the browser's `EventSource` constructor has nowhere to put that header.
Setting `withCredentials: true` does not change that.

The [**@microsoft/fetch-event-source package**](https://github.com/Azure/fetch-event-source) gives you control over the HTTP request while consuming the same `text/event-stream` response.
It works with the endpoint from [**Server-Sent Events in ASP.NET Core and .NET 10**](https://milanjovanovic.tech/blog/server-sent-events-in-aspnetcore-and-dotnet-10), provided that endpoint is configured to accept bearer authentication.

## When Should You Use fetch-event-source Instead of EventSource?

Start with the request your application needs to make.
Native [**EventSource**](https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface) is enough for a GET stream authenticated with applicable cookies.
Adding a dependency only earns its place when you need more control.

| Requirement | Native EventSource | fetch-event-source |
| --- | --- | --- |
| Same-origin cookie authentication | Supported automatically | Supported by Fetch defaults |
| Custom Authorization header | Not supported | Set headers.Authorization |
| POST with a JSON body | Not supported | Set method, headers, and body |
| Inspect HTTP status before streaming | No response callback | Validate in onopen |
| Application retry policy | Browser manages reconnection | Choose a delay or stop in onerror |
| Named SSE events | Use addEventListener | Inspect message.event in onmessage |
| Intentional shutdown | Call close() | Abort the supplied signal |

Plain `fetch()` exposes a byte stream, not an SSE subscription.
Network chunks can split a line or contain several events, so parsing each chunk as JSON is incorrect.
The SSE parser must also handle multiline `data` fields and event boundaries.

## Send a Bearer Token and Read Events

Install the client in your frontend project:

```bash
npm install @microsoft/fetch-event-source
```

For a first connection, pass the access token from your existing authentication flow:

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

export async function readOrders(accessToken, signal) {
  await fetchEventSource('/orders/realtime/with-replays', {
    signal,
    headers: {
      Authorization: `Bearer ${accessToken}`
    },
    onmessage(message) {
      if (message.event === 'orders' && message.data) {
        console.log(JSON.parse(message.data));
      }
    }
  });
}
```

This is a minimal request example; add the error policy below before using it for a persistent subscription.
Unlike native `EventSource.onmessage`, the library's `onmessage` receives named events too, so check `message.event`.
The callback's `data` is the payload string, not a wrapper object with another `data` property.

Keep the token out of query strings, where URLs can end up in logs.
The API must still validate the [**JWT issuer, audience, signature, and lifetime**](https://milanjovanovic.tech/blog/jwt-authentication-aspnetcore) and apply `.RequireAuthorization()`.
An SSE client library does not configure server authentication.

## Validate Responses and Set a Retry Policy

A proxy can return HTML, an expired token can produce `401`, and an overloaded service can return `503`.
Those should not all produce the same retry loop.

Put this reusable client in `order-stream.js`.
It stops on authentication failures and unexpected response formats, retries network failures and selected HTTP statuses, and limits consecutive failures:

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

class FatalStreamError extends Error {}

class RetryableStreamError extends Error {
  constructor(message, retryAfterMs = 0) {
    super(message);
    this.retryAfterMs = retryAfterMs;
  }
}

function retryAfterMs(response) {
  const value = response.headers.get('retry-after');
  if (!value) return 0;

  const seconds = Number(value);
  if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;

  const date = Date.parse(value);
  return Number.isNaN(date) ? 0 : Math.max(0, date - Date.now());
}

export async function subscribeToOrders({ accessToken, signal, onOrder }) {
  if (signal.aborted) return;
  let failures = 0;

  await fetchEventSource('/orders/realtime/with-replays', {
    signal,
    openWhenHidden: true,
    headers: {
      Authorization: `Bearer ${accessToken}`
    },
    async onopen(response) {
      if ([408, 429].includes(response.status) || response.status >= 500) {
        throw new RetryableStreamError(
          `HTTP ${response.status}`,
          retryAfterMs(response)
        );
      }

      if (response.status !== 200) {
        throw new FatalStreamError(`HTTP ${response.status}`);
      }

      const mediaType = response.headers
        .get('content-type')?.split(';')[0].trim().toLowerCase();

      if (mediaType !== 'text/event-stream') {
        throw new FatalStreamError('Expected an SSE response');
      }
    },
    onmessage(message) {
      if (signal.aborted || message.event !== 'orders' || !message.data) return;

      let order;
      try {
        order = JSON.parse(message.data);
      } catch {
        throw new FatalStreamError('Invalid order JSON');
      }

      try {
        onOrder(order, message.id);
      } catch {
        throw new FatalStreamError('Order handler failed');
      }
      failures = 0;
    },
    onclose() {
      throw new RetryableStreamError('Order stream closed');
    },
    onerror(error) {
      if (error instanceof FatalStreamError) throw error;
      if (++failures > 5) throw error;

      const backoff = Math.min(1000 * 2 ** (failures - 1), 30000);
      const serverDelay = error instanceof RetryableStreamError
        ? error.retryAfterMs
        : 0;

      return Math.max(backoff + Math.random() * 500, serverDelay);
    }
  });
}
```

Providing `onopen` replaces the library's default validation, so check the response content type yourself.
Allowing a `charset` parameter avoids rejecting a valid `text/event-stream; charset=utf-8` response.
The sample treats `204` as terminal too; the server and client should agree on what each terminal status means.

Returning a delay from `onerror` schedules another attempt.
**Throwing from `onerror` stops retries** and rejects the promise; returning `undefined` does not stop them.
The [**client implementation**](https://github.com/Azure/fetch-event-source/blob/main/src/fetch.ts) also completes on a normal end of the response unless `onclose` throws.

Here, five retries are allowed between successfully handled orders.
An idle connection that repeatedly closes eventually stops, even if each request initially returns `200`.
Resetting the counter in `onopen` would let that failure loop continue indefinitely.

The numeric return overrides the library's current retry interval, including an interval supplied by an SSE `retry:` field.
This example honors HTTP `Retry-After` for retryable responses and adds jitter to the local backoff.
For very long server delays, pause the subscription and show a retry action instead of keeping an arbitrarily long browser timer.

Keep `onOrder` synchronous here.
The library does not await an async `onmessage` callback, and JSON parsing alone does not validate your application's payload schema.
Validate the order fields before updating state when that contract is not already enforced elsewhere.

## How Do You Use fetch-event-source in React?

Start the subscription in an effect, and create its `AbortController` inside that effect.
Here is an `OrderNotifications.jsx` component using the helper above:

```jsx
import { useEffect, useState } from 'react';
import { subscribeToOrders } from './order-stream.js';

export function OrderNotifications({ accessToken }) {
  const [latest, setLatest] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLatest(null);
    setError(null);
    if (!accessToken) return;

    const controller = new AbortController();

    void subscribeToOrders({
      accessToken,
      signal: controller.signal,
      onOrder(order) {
        if (!controller.signal.aborted) setLatest(order);
      }
    }).catch((failure) => {
      if (!controller.signal.aborted) setError(failure.message);
    });

    return () => controller.abort();
  }, [accessToken]);

  if (!accessToken) return <p>Sign in to see order updates.</p>;
  if (error) return <p role="alert">Order updates stopped: {error}</p>;
  return <pre>{latest ? JSON.stringify(latest, null, 2) : 'Waiting for orders...'}</pre>;
}
```

Cleanup runs when the component unmounts and before the effect restarts for a new token.
[**React Strict Mode**](https://react.dev/reference/react/useEffect) also exercises setup and cleanup an extra time in development.
A fresh controller per effect and cancellation guards keep old callbacks from updating the current view.

Treat `401` as a handoff to your authentication flow.
Refresh or reacquire the token there, then supply the new token to restart the effect.
Retrying with the same expired header does not refresh it.

The helper sets `openWhenHidden: true` so this example keeps its connection while the tab is hidden, subject to browser and network constraints.
By default the library closes on hide and reopens on visibility, which reduces open connections but makes replay more important.
Keeping a hidden connection open costs server resources and does not guarantee background execution on mobile devices.

## Send a POST Body or Use Cross-Origin Authentication

A streaming search endpoint might need a filter too large or complex for a query string.
The same client can send JSON using `method: 'POST'`, a `Content-Type: application/json` header, and `body: JSON.stringify(filter)`.
The server must explicitly accept POST and still return an SSE response.

Choose what happens when the request is repeated.
Retrying a POST that creates a job can create the job twice, so use [**idempotency**](https://milanjovanovic.tech/blog/implementing-idempotent-rest-apis-in-aspnetcore) or create the job once and subscribe to a separate GET endpoint.
For a finite search or generation response, let normal EOF complete instead of copying the persistent subscription's throwing `onclose`.

Cross-origin calls need a matching [**ASP.NET Core CORS policy**](https://learn.microsoft.com/en-us/aspnet/core/security/cors?view=aspnetcore-10.0).
For bearer requests, allow the frontend origin, request method, and headers such as `Authorization`, `Content-Type`, and `Last-Event-ID`.
Expose `Retry-After` if the client needs to read that response header.

For cross-origin cookie authentication, use Fetch's `credentials: 'include'` option.
The server must allow credentials with an explicit origin, and cookie SameSite and browser third-party-cookie rules still apply.
Do not use native EventSource's `withCredentials` option on a fetch-based request.

## What Does Last-Event-ID Actually Guarantee?

For retries within one call, the library remembers IDs received in the stream and sends a `last-event-id` header on the next request.
A new call, including a React effect restarted after token refresh, starts with new state.
To resume across calls, retain an appropriate cursor and supply it explicitly, scoped to the user and subscription.

A cursor is not an acknowledgement that your application durably processed an event.
The parser can record an ID before your handler finishes.
If reliable processing matters, define an application checkpoint, replay from that checkpoint, and make handlers tolerate duplicates.

The server must retain events, authorize the replay, and coordinate the transition from historical events to live updates.
If history has expired, fetch a fresh snapshot instead of pretending the stream is complete.
For UI notifications, **persisting the notification before pushing a hint** lets the client recover by reading current state.

The client also cannot repair incorrect server distribution.
A shared .NET `ChannelReader` gives each item to one reader; two tabs compete for updates.
Use a subscription per connection and route by authenticated identity, as described in the [**SSE endpoint guide**](https://milanjovanovic.tech/blog/server-sent-events-in-aspnetcore-and-dotnet-10#filtering-server-sent-events-by-user).

## Summary

- **Choose the client from the request requirements.** Native EventSource handles cookie-authenticated GET streams; fetch-event-source adds bearer headers, bodies, and response callbacks.
- **Make failures explicit.** Validate status and content type, retry temporary failures with limits, and stop on credentials or payload errors.
- **Tie connections to the view's lifetime.** Abort in React cleanup and restart with a fresh token after authentication changes.
- **Design delivery on the server.** Reconnection needs retained history and per-connection routing if every tab must receive every event.

For an existing cookie-authenticated GET stream, native EventSource may already do everything you need.
Use the additional control when your authentication or failure-handling requirements call for it.

## Frequently asked questions

### What is fetch-event-source?

@microsoft/fetch-event-source is a JavaScript library that consumes server-sent events using Fetch. It supports custom headers, request methods and bodies, response validation, and a configurable retry policy while parsing the SSE wire format.

### Can EventSource send an Authorization header?

Native browser EventSource cannot set custom headers. It supports cookie authentication, including cross-origin cookies with withCredentials and compatible CORS and cookie policies. Use a fetch-based client to send Authorization: Bearer followed by an access token.

### How do you stop fetch-event-source from retrying?

Throw from onerror to reject the operation and stop retries. Returning a number schedules another attempt after that many milliseconds; returning undefined keeps the library retry interval. Abort the supplied AbortController to cancel a connection intentionally.

### Does fetch-event-source reconnect when the server closes the stream?

A normal end of the response completes the operation unless onclose throws. For a persistent subscription, throw in onclose and let onerror choose a retry delay. For a finite stream, allow the operation to finish normally.

### How do you use fetch-event-source in React?

Start the subscription inside useEffect, create a fresh AbortController for each effect, and abort it in cleanup. Guard callbacks after cancellation, handle the returned promise rejection, and restart the effect when the access token changes.

### Does Last-Event-ID guarantee delivery?

No. A cursor tells the server where a connection left off. The server must retain authorized events and implement replay, while the application must handle duplicates and gaps. A new fetchEventSource call does not inherit the cursor from the previous call.
