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

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

By

9 min read··

aspnetcorejavascriptreactreal-time

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 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, 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 is enough for a GET stream authenticated with applicable cookies. Adding a dependency only earns its place when you need more control.

RequirementNative EventSourcefetch-event-source
Same-origin cookie authenticationSupported automaticallySupported by Fetch defaults
Custom Authorization headerNot supportedSet headers.Authorization
POST with a JSON bodyNot supportedSet method, headers, and body
Inspect HTTP status before streamingNo response callbackValidate in onopen
Application retry policyBrowser manages reconnectionChoose a delay or stop in onerror
Named SSE eventsUse addEventListenerInspect message.event in onmessage
Intentional shutdownCall 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:

npm install @microsoft/fetch-event-source

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

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 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:

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 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:

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 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 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. 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.

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.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.