This article was originally published on Jo4 Blog.
We had a React notification bell that polled /notifications/unread-count every 30 seconds. It worked. It also sent thousands of pointless requests when the answer was "still zero, still zero, still zero." We replaced it with Server-Sent Events.
The pattern looks small from the outside — open a stream, listen for events, update state. The reality has more sharp edges than I expected. Here's the implementation that survived production.
The TL;DR
- Native browser
EventSourcecan't send custom headers, so it can't carry anAuthorizationBearer token. Use@microsoft/fetch-event-sourceinstead. - The bell still does a one-shot bootstrap fetch via RTK Query so first paint isn't blank. SSE drives subsequent updates.
- SSE updates are pushed into the same RTK Query cache the bootstrap fetch wrote into, so every reader of
useGetUnreadCountQueryupdates without prop drilling. - Reconnect with exponential backoff. Force logout on 401. Survive tab backgrounding.
The Component
const SSE_ENABLED = import.meta.env.VITE_USE_SSE_NOTIFICATIONS === 'true';
const POLL_INTERVAL_FALLBACK_MS = 30_000;
export function NotificationBell() {
const dispatch = useAppDispatch();
// Bootstrap: one-shot for first paint. SSE drives subsequent updates when on,
// RTK Query polls when off — easy kill switch via env var.
const { data: unreadData } = useGetUnreadCountQuery(
undefined,
SSE_ENABLED ? undefined : { pollingInterval: POLL_INTERVAL_FALLBACK_MS },
);
const streamRef = useRef<{ close: () => void } | null>(null);
useEffect(() => {
if (!SSE_ENABLED) return;
const apiBase = import.meta.env.VITE_API_BASE ?? '';
streamRef.current = subscribeToNotifications(apiBase, (count) => {
// Patch RTK Query's cache so every reader of useGetUnreadCountQuery
// updates, not just this component.
dispatch(
notificationApi.util.updateQueryData(
'getUnreadCount',
undefined,
(draft) => {
if (draft?.response) {
draft.response.count = count;
}
},
),
);
});
return () => {
streamRef.current?.close();
streamRef.current = null;
};
}, [dispatch]);
const unreadCount = unreadData?.response?.count ?? 0;
// ... render bell ...
}
A few choices to call out:
Feature flag with a polling fallback. SSE_ENABLED is an env var. When it's off, the same component falls back to RTK Query's pollingInterval. This is the kill switch you'll thank yourself for the first time SSE behaves badly behind a customer's corporate proxy.
updateQueryData, not local state. The bootstrap fetch and the SSE updates write into the same RTK Query cache key. Any other component that calls useGetUnreadCountQuery — a sidebar, a modal — re-renders automatically when the SSE stream pushes a new count. Local component state would have meant prop drilling or a parallel state tree.
streamRef for the close handle. The cleanup callback closes the stream on unmount. The ref pattern is necessary because the stream object is created inside useEffect but referenced from the cleanup closure.
The Subscribe Function
This is the gnarly part. EventSource is the native browser API, but it's a non-starter:
"EventSource cannot send custom headers."
That's a hard limit — you can't pass an Authorization: Bearer <jwt> header. There's a workaround (cookie auth + withCredentials) but we're not using cookies for our API. So: @microsoft/fetch-event-source, which is a fetch-based polyfill that supports headers and exposes lifecycle hooks.
import { fetchEventSource, type EventSourceMessage }
from '@microsoft/fetch-event-source';
import { getAccessToken, forceLogout } from '../../utils/authTokenManager';
const RECONNECT_INITIAL_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 30_000;
const RECONNECT_BACKOFF_FACTOR = 2;
class FatalAuthError extends Error {
constructor() {
super('Auth failed for SSE stream');
this.name = 'FatalAuthError';
}
}
export function subscribeToNotifications(
apiBase: string,
onUnreadCount: (count: number) => void,
): { close: () => void } {
const controller = new AbortController();
let closed = false;
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
const url = `${apiBase}/api/v1/protected/notifications/stream`;
const handleMessage = (msg: EventSourceMessage) => {
if (msg.event !== 'unread-count' || !msg.data) return;
try {
const payload = JSON.parse(msg.data);
if (typeof payload.count === 'number') {
onUnreadCount(payload.count);
// Successful event = healthy connection; reset backoff.
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
}
} catch (err) {
console.warn('[notificationStream] malformed event payload', err);
}
};
const connect = async () => {
if (closed) return;
const token = await getAccessToken();
if (!token) {
// Auth state hasn't settled yet; retry after delay.
scheduleReconnect();
return;
}
try {
await fetchEventSource(url, {
signal: controller.signal,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
// Critical: don't reload on tab visibility changes (default behavior).
openWhenHidden: true,
async onopen(response) {
if (response.status === 401 || response.status === 403) {
throw new FatalAuthError();
}
if (!response.ok) {
throw new Error(`SSE handshake failed: ${response.status}`);
}
},
onmessage: handleMessage,
onclose() {
// Server closed the stream (e.g., 30-min lifetime cap). Reconnect.
if (!closed) scheduleReconnect();
},
onerror(err) {
if (err instanceof FatalAuthError) {
closed = true;
forceLogout();
throw err; // stop fetchEventSource's retry loop
}
// Network blip — bail out and let our catch handle reconnect.
throw err;
},
});
} catch (err) {
if (err instanceof FatalAuthError || closed) return;
console.warn('[notificationStream] disconnected, reconnecting', err);
scheduleReconnect();
}
};
const scheduleReconnect = () => {
if (closed) return;
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * RECONNECT_BACKOFF_FACTOR,
RECONNECT_MAX_DELAY_MS);
setTimeout(() => { void connect(); }, delay);
};
void connect();
return {
close() {
closed = true;
controller.abort();
},
};
}
The Sharp Edges
openWhenHidden: true. The default behavior of fetch-event-source is to close the stream when the tab is backgrounded and reopen on focus. This sounds reasonable until you realize that's exactly when you most want the connection to stay alive — so the unread count is up to date when the user comes back. Set it to true.
401/403 = hard stop, not retry. A network blip is recoverable. An auth failure is not — the token is bad, retrying every second won't fix it. We throw a tagged FatalAuthError, force-logout, and break out of the retry loop. Without this, a stale token caused our SSE client to hammer the server with retries forever.
Reset backoff on successful event. Without this, a connection that hiccups, reconnects, runs fine for an hour, then hiccups again would start the second reconnect at the previous attempt's max delay. Resetting backoff on every successful event means each disconnect starts fresh.
Server onclose is normal. Our backend caps stream lifetime at 30 minutes (so JWT expiry is handled by reconnect). The client should treat onclose as routine: schedule reconnect, log nothing scary. Don't surface it as an error.
AbortController for cleanup. fetchEventSource doesn't expose a "stop" method; the only way to terminate it is via the signal. Wire up controller.abort() in the close handler.
Cache Patching, Not Local State
The single most useful pattern in this whole thing:
dispatch(
notificationApi.util.updateQueryData('getUnreadCount', undefined, (draft) => {
if (draft?.response) {
draft.response.count = count;
}
}),
);
updateQueryData is RTK Query's escape hatch for updating cached data outside of normal query/mutation flow. It mutates the cache as if the query had refetched and gotten the new value. Every component subscribed to useGetUnreadCountQuery re-renders. No new endpoint, no new slice, no prop drilling.
This is the same primitive RTK Query exposes for optimistic updates. Repurposing it for "we got a server-pushed update" is uncontroversial and pays off the moment a second component (a sidebar count, a modal indicator) needs the same data.
What We Didn't Do
A few patterns we considered and skipped:
-
WebSockets. Overkill for one-way server-to-client updates. SSE is simpler, has built-in reconnect semantics in browsers, and works through every HTTP intermediary that supports
Transfer-Encoding: chunked. - Long polling. Same byte cost as SSE in steady state, more complicated state machine, no native reconnect helper.
- Mobile SSE. Mobile apps use FCM/APNs push for real-time, plus an on-resume REST call for the unread count when the app foregrounds. JS runtimes on mobile suspend when backgrounded, which makes long-lived SSE unworkable.
Lessons Learned
-
EventSourcecan't send headers. If you do bearer-token auth, you need afetch-based SSE client (we use@microsoft/fetch-event-source). -
openWhenHidden: trueis almost always what you want. The default of "close on tab hide" is exactly backwards for an idle long-lived stream. - Treat 401/403 differently from network errors. Auth failures are unrecoverable from the client's perspective — force logout, stop retrying. Network blips are recoverable — exponential backoff.
- Reset backoff on successful events, not just on successful reconnects. A long-running stream that hiccups should re-enter retry at the initial delay, not the previous attempt's max delay.
- Server-side stream lifetime caps are a feature, not a bug. They give you a clean reconnect boundary that handles mid-stream JWT expiry without any explicit re-validation logic.
-
RTK Query's
updateQueryDatais the right place to put server-pushed updates. Cache patches propagate to every subscriber. Local component state forces prop drilling. - Always make new transports feature-flagged with a polling fallback. First time SSE behaves badly behind a customer's corporate proxy, you'll flip the flag and forget about it.
Have you migrated polling to SSE in production? What's the gotcha that bit you? Drop it in the comments.
Building jo4.io — a URL shortener whose real-time updates work the way the user expects, even when the network does not.
Top comments (0)