Notification SSE stream retries infinitely when session expires #203

Closed
opened 2026-04-07 11:08:56 +02:00 by marcel · 0 comments
Owner

Context

NotificationBell.svelte opens an EventSource to /api/notifications/stream on mount (line 130). The browser's EventSource API automatically reconnects on any error -- this is by design for transient network failures, but becomes a problem when the server returns 401/403 due to an expired session.

What happens

  1. User's session expires (or credentials become invalid)
  2. EventSource reconnect fires a request to /api/notifications/stream
  3. Spring Security rejects it (all non-public endpoints require authentication -- SecurityConfig.java line 63: auth.anyRequest().authenticated())
  4. EventSource sees an error, waits ~3 seconds, retries -- forever
  5. The browser spams the server with unauthenticated requests in a tight retry loop

Impact

  • Network spam: continuous failing requests from every tab with an expired session
  • Server load: each retry hits the Spring Security filter chain and returns a 401
  • No user feedback: the user is not redirected to login; the UI silently degrades (no new notifications arrive, but no error is shown)

Root cause

NotificationBell.svelte lines 128-137 -- the EventSource setup has no onerror handler:

onMount(() => {
    fetchUnreadCount();
    eventSource = new EventSource('/api/notifications/stream');
    eventSource.addEventListener('notification', (e) => {
        const notification = parseNotificationEvent(e.data);
        if (!notification) return;
        notifications = [notification, ...notifications];
        if (!notification.read) unreadCount += 1;
    });
    // no onerror handler -- EventSource retries indefinitely on 401
});

The EventSource API does not expose the HTTP status code in its onerror event, so the client cannot distinguish a 401 from a network hiccup purely from the error event. However, EventSource.readyState === EventSource.CLOSED indicates the browser gave up (which happens on non-retryable errors like 401 in most browsers), vs EventSource.CONNECTING which means it is retrying.

Proposed fix

Add an onerror handler that:

  1. Checks eventSource.readyState -- if CLOSED, the server rejected the connection (likely 401). Close and stop.
  2. If CONNECTING (browser is retrying), probe the session with a lightweight fetch to /api/notifications/unread-count. If that returns 401, close the EventSource and redirect to /login.
  3. Optionally add a retry counter to cap reconnection attempts even for transient failures.
eventSource.onerror = async () => {
    if (eventSource?.readyState === EventSource.CLOSED) {
        eventSource.close();
        return;
    }
    // Probe session validity
    const res = await fetch('/api/notifications/unread-count');
    if (res.status === 401) {
        eventSource?.close();
        window.location.href = '/login';
    }
};

Files involved

  • frontend/src/lib/components/NotificationBell.svelte -- needs onerror handler (primary fix)
  • backend/src/main/java/org/raddatz/familienarchiv/service/SseEmitterRegistry.java -- no changes needed, already cleans up on error/timeout
  • backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java -- no changes needed
## Context `NotificationBell.svelte` opens an `EventSource` to `/api/notifications/stream` on mount (line 130). The browser's `EventSource` API automatically reconnects on any error -- this is by design for transient network failures, but becomes a problem when the server returns 401/403 due to an expired session. ## What happens 1. User's session expires (or credentials become invalid) 2. `EventSource` reconnect fires a request to `/api/notifications/stream` 3. Spring Security rejects it (all non-public endpoints require authentication -- `SecurityConfig.java` line 63: `auth.anyRequest().authenticated()`) 4. `EventSource` sees an error, waits ~3 seconds, retries -- forever 5. The browser spams the server with unauthenticated requests in a tight retry loop ## Impact - **Network spam**: continuous failing requests from every tab with an expired session - **Server load**: each retry hits the Spring Security filter chain and returns a 401 - **No user feedback**: the user is not redirected to login; the UI silently degrades (no new notifications arrive, but no error is shown) ## Root cause `NotificationBell.svelte` lines 128-137 -- the `EventSource` setup has no `onerror` handler: ```js onMount(() => { fetchUnreadCount(); eventSource = new EventSource('/api/notifications/stream'); eventSource.addEventListener('notification', (e) => { const notification = parseNotificationEvent(e.data); if (!notification) return; notifications = [notification, ...notifications]; if (!notification.read) unreadCount += 1; }); // no onerror handler -- EventSource retries indefinitely on 401 }); ``` The `EventSource` API does not expose the HTTP status code in its `onerror` event, so the client cannot distinguish a 401 from a network hiccup purely from the error event. However, `EventSource.readyState === EventSource.CLOSED` indicates the browser gave up (which happens on non-retryable errors like 401 in most browsers), vs `EventSource.CONNECTING` which means it is retrying. ## Proposed fix Add an `onerror` handler that: 1. Checks `eventSource.readyState` -- if `CLOSED`, the server rejected the connection (likely 401). Close and stop. 2. If `CONNECTING` (browser is retrying), probe the session with a lightweight fetch to `/api/notifications/unread-count`. If that returns 401, close the `EventSource` and redirect to `/login`. 3. Optionally add a retry counter to cap reconnection attempts even for transient failures. ```js eventSource.onerror = async () => { if (eventSource?.readyState === EventSource.CLOSED) { eventSource.close(); return; } // Probe session validity const res = await fetch('/api/notifications/unread-count'); if (res.status === 401) { eventSource?.close(); window.location.href = '/login'; } }; ``` ## Files involved - `frontend/src/lib/components/NotificationBell.svelte` -- needs `onerror` handler (primary fix) - `backend/src/main/java/org/raddatz/familienarchiv/service/SseEmitterRegistry.java` -- no changes needed, already cleans up on error/timeout - `backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java` -- no changes needed
marcel added the bug label 2026-04-07 11:09:00 +02:00
Sign in to join this conversation.
No Label bug
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#203