This is the story of a subtle workflow error that polluted our logs and kept triggering pointless alerts. It lingered in the background long enough. I decided to track it down and fix it for good. Enough is enough.
In my client’s system, we rely on Server-Sent Events (SSE) to stream web notifications to active users on our client apps. Something along this flow triggered 401 Unauthorized errors constantly.
The SSE workflow
- Client app - opens a connection (type
text/event-stream) to receive these notifications - Server - authenticates the user
- subscribes it to the messages stream
- sends events (messages and/or data) to the client
- Connection’s lifetime ends when either the client disconnects or the server cancels the connection
As our auth provider, we use Clerk. Our frontend apps, built with Vite, are implementing the Clerk SDK (@clerk/clerk-react).
The problem
At certain intervals during this process, the SSE requests failed with 401 Unauthorized. When users were active on the app, the number of failures compounded. This triggered the alerts we configured in the Azure Monitoring portal, and those alerts were sent to our Slack. It became annoying as they clouded other real alerts from our system.

And the Azure logs:

The symptoms
I noticed the first request failed, followed by successful requests. What made that first request fail? I looked at the difference between them.
A few symptoms started to stand out in those failed requests:
- The request type was
plaininstead ofeventsource - The bearer token was different. This was a tell-tale sign, as the new token didn’t change in the successful requests.
- The failed one seemed like it got triggered once the app was accessed (switched tab to it)
This last symptom had me thinking, it probably is related to an event firing. While digging around DevTools for clues, I checked at the stack trace. Comparing the stack traces from the two requests offered plenty of clues on what went wrong.
The stack trace from the 401 requests:
create
fetch.ts:105:40
onVisibilityChange
fetch.ts:145:9
(Async: EventListener.handleEvent) fetchEventSource/<
fetch.ts:67:12
fetchEventSource
sse.ts:64:13
connect
RealtimeNotificationsProvider.tsx:122:22
initializeSSE
And the one resulting in 200:
create
fetch.ts:105:40
fetchEventSource/<
fetch.ts:145:9
fetchEventSource
fetch.ts:67:12
connect
sse.ts:64:13
scheduleReconnect/this.reconnectTimeout<
RealtimeNotificationsProvider.tsx:122:22
(Async: setTimeout handler) scheduleReconnect
RealtimeNotificationsProvider.tsx:125:10
onerror
react-dom-client.development.js:25989:20
create
I noticed the onVisibilityChange was right before the 401 error. This shines some light as it might be related to the browser visibility event.
After googling for “onVisibilityChange fetch”, among the first results, I found https://github.com/Azure/fetch-event-source#readme, the same library we’re using handling SSE.
In their README:
In addition, this library also plugs into the browser’s Page Visibility API so the connection closes if the document is hidden (e.g., the user minimizes the window), and automatically retries with the last event ID when it becomes visible again
Aha! Just like in the symptom I observed, it connects when the app comes into focus. So while the user is not looking at the app (so to speak) the connection is closed. As soon as the app is visible again, it retries to connect.
The code
It was still not very clear why the authorization failed. Time to paste the code here. Well, parts of the code:
async connect(): Promise<void> {
if (this.isConnecting) {
return;
}
this.isConnecting = true;
try {
const token = await this.options.getToken();
if (!token) {
this.isConnecting = false;
return;
}
[...]
await fetchEventSource(this.options.url, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
signal: this.abortController.signal,
onmessage: event => {
[...]
},
onerror: error => {
// Only reconnect if not aborted (user-initiated disconnect)
[...]
},
});
} catch (error) {
// Only reconnect if not aborted (user-initiated disconnect)
[...]
} finally {
this.isConnecting = false;
}
}
I kept experimenting until I noticed the token gets cached. We pass the headers to fetchEventSource, and those same headers are reused when the visibility change, they’re passed down to the library’s internal create() call.
So we pass the valid token for SSE and it works well. But it eventually fails, and the token gets changed by then.
Can the token be rotated while we “look away” (the app in the background)? I went through the Clerk docs and finally I found the root cause.
The bug
Clerk issues tokens with very short lifespan (that’s 60 seconds short). And uses a token refresh mechanism that is triggered automatically by the frontend SDKs.
This and the token being cached was the cause of us getting all those 401 Unauthorized errors.
- SSE connects with token A (valid) - the token is passed to
fetchEventSource - User switches tabs - the library closes the connection
- Clerk rotates the token - token A expires, replaced by token B
- User returns to the tab - the internal
onVisibilityChangehandler fires and tries to reconnect while re-using the same static headers object from step 1. It sends the expired token A - Our server returns 401 (token A is expired)
- The 401 triggers our
onerrorwhich eventually callsgetToken(). This refreshes the token B
The fix
I needed to make sure fetchEventSource has always the latest token. So a refresh mechanism was required.
As a helpful aid, the fetch library allows passing a custom fetch.
So instead of static headers, I could pass my custom fetch method. This way I can construct the headers dynamically. And ensure the connection always uses the latest credentials.
My new code looks like this:
[...]
await fetchEventSource(this.options.url, {
method: 'GET',
fetch: async (input, init) => {
const freshToken = await this.options.getToken();
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${freshToken}`);
headers.set('Accept', 'text/event-stream');
return globalThis.fetch(input, { ...init, headers });
},
signal: this.abortController.signal,
onmessage: event => { [...] },
onerror: error => { [...] },
});
[...]
The wins
Investigating this was a pleasant and rewarding journey, which got me more used to dive into the source code.
The change itself is small, but one that compounds: every user, every tab switch, every day.
Right now, there is less noise in our server logs, and less “fake” alerts getting triggered. So we have a better visibility, the real issues stand out instead of getting buried in the noise. We can focus on what matters.
Most importantly, this improved the user experience. When users switch back to the app, notifications arrive instantly. No more waiting 5-seconds reconnectDelay to kick in.