Using the Page Visibility API to Pause Event Streams Permalink to this section

Part of Mobile & Background-Tab Handling.

An EventSource connection stays open indefinitely by design β€” the browser holds a persistent TCP connection and the server keeps a slot allocated for it. When the user switches tabs, locks their phone, or moves the app to the background, the connection keeps flowing: the server sends events, the browser buffers them silently, and mobile radios stay active. On a device with 50 open browser tabs this multiplies into real battery drain and wasted server file descriptors.

The Page Visibility API solves this cleanly. Listening to document.visibilitychange lets you tear down the EventSource when the page goes hidden and rebuild it β€” with the correct Last-Event-ID β€” when the page becomes visible again. No polling, no timers, no framework magic required.

Symptom & Developer Intent Permalink to this section

Observable behaviors that lead engineers here:

  • Mobile users report faster battery drain on pages that use live feeds.
  • Server connection counts remain high even when most users are idle (background tabs).
  • Chrome DevTools Network panel shows an open SSE stream with no traffic for minutes while the tab is in the background.
  • Memory profiles show event data accumulating in a hidden tab’s JavaScript heap.
  • Load tests show that concurrent connection counts don’t drop even when all browser windows are backgrounded.

The developer intent is to disconnect the EventSource when document.hidden === true and reconnect with the last received event ID when document.hidden === false, so no server slot is wasted on an invisible tab and no client-side buffer grows unbounded.

Root Cause Analysis Permalink to this section

Why EventSource Keeps Running in the Background Permalink to this section

The SSE protocol specifies nothing about application-layer lifecycle: the browser’s EventSource implementation is a streaming HTTP client. It opens a connection, reads until the server closes it or a network error occurs, then waits retry milliseconds and reconnects. It has no awareness of tab visibility.

Browsers throttle JavaScript timers and requestAnimationFrame for hidden tabs, but they do not suspend active HTTP responses. The reason is intentional: browser specs assume that a backgrounded tab might be legitimately receiving background data (push notifications, progress updates). So EventSource is exempt from throttling.

The practical consequences at the protocol level:

Layer What happens when tab is hidden
TCP Connection stays open; OS keeps keepalive packets flowing
HTTP/1.1 Server streams Transfer-Encoding: chunked chunks; browser reads them
HTTP/2 Server pushes DATA frames on the multiplexed stream
EventSource buffer Browser appends parsed events to the internal queue
JS event loop onmessage handlers fire normally (no throttle for network I/O)
Mobile radio LTE/5G radio stays in high-power state (RRC_CONNECTED)

Why the Default Reconnect Behavior Doesn’t Help Permalink to this section

The event ID and retry mechanism is designed for accidental disconnections, not intentional ones. If you just set eventSource.close() without storing the last ID, the browser forgets it. On reconnect, EventSource sends no Last-Event-ID header and the server replays nothing β€” you silently lose events that arrived during the hidden interval.

Correct pausing requires two coordinated pieces: storing the last seen id field and passing it back via the URL or request header on reconnect.

Step-by-Step Resolution Permalink to this section

Step 1 β€” Track the Last Received Event ID Permalink to this section

Intercept every message event (and any named events) to capture the current ID before you might close the connection.

// visibility-sse.js

let lastEventId = null;

function attachIdTracker(es) {
  // The browser exposes the running last-event-id as es.url is not helpful;
  // we must capture it from each event ourselves.
  es.addEventListener('message', (e) => {
    if (e.lastEventId) lastEventId = e.lastEventId;
    handleEvent(e);
  });

  // If you use named event types, mirror the tracking there too.
  es.addEventListener('update', (e) => {
    if (e.lastEventId) lastEventId = e.lastEventId;
    handleUpdate(e);
  });
}

MessageEvent.lastEventId reflects the most recent id: field seen in the stream β€” it persists across event types on the same connection, matching the WHATWG spec’s β€œlast event ID” buffer.

Step 2 β€” Build a Connect/Disconnect Helper Permalink to this section

Encapsulate open and close so the visibility handler stays simple.

// visibility-sse.js (continued)

const SSE_BASE = '/api/events';
let es = null;

function connect() {
  if (es && es.readyState !== EventSource.CLOSED) return; // already open

  const url = new URL(SSE_BASE, location.origin);
  if (lastEventId !== null) {
    // Pass the last ID as a query param; read it server-side and emit
    // a synthetic `id:` field on the first event so the browser resumes.
    url.searchParams.set('lastEventId', lastEventId);
  }

  es = new EventSource(url.toString(), { withCredentials: true });
  attachIdTracker(es);

  es.onerror = (e) => {
    console.warn('SSE error', e, 'readyState:', es.readyState);
    // EventSource will retry automatically on transient errors;
    // only hard-close on intentional visibility-driven pauses.
  };
}

function disconnect() {
  if (!es) return;
  es.close(); // sets readyState to CLOSED immediately; no retry fires
  es = null;
}

Note on Last-Event-ID header vs query param: The browser sends Last-Event-ID as a request header automatically only when it internally reconnects after a network failure. When you call new EventSource(url) from scratch, the browser sends no such header. Passing the ID as a query parameter is the standard workaround β€” your server reads it and fast-forwards the stream accordingly.

Step 3 β€” Wire Up visibilitychange Permalink to this section

// visibility-sse.js (continued)

function handleVisibilityChange() {
  if (document.hidden) {
    disconnect();
  } else {
    connect();
  }
}

// Initial connect on page load (only if already visible).
if (!document.hidden) {
  connect();
}

document.addEventListener('visibilitychange', handleVisibilityChange);

The visibilitychange event fires on:

  • Tab switch (foreground β†’ background and back)
  • Alt+Tab or Cmd+Tab away from the browser
  • Phone screen lock / unlock
  • Browser window minimise (on some platforms)
  • Mobile browser moving to background (home button, swipe up)

Step 4 β€” React Hook (Production Pattern) Permalink to this section

For React apps, encapsulate the entire lifecycle in a hook that is safe under Strict Mode double-mount.

// useVisibilitySSE.ts
import { useEffect, useRef, useCallback } from 'react';

type MessageHandler = (event: MessageEvent) => void;

export function useVisibilitySSE(
  url: string,
  onMessage: MessageHandler,
  eventTypes: string[] = []
) {
  const esRef = useRef<EventSource | null>(null);
  const lastIdRef = useRef<string | null>(null);
  // Stable ref to avoid re-registering on every render
  const onMessageRef = useRef(onMessage);
  onMessageRef.current = onMessage;

  const connect = useCallback(() => {
    if (esRef.current?.readyState === EventSource.OPEN) return;

    const target = new URL(url, location.origin);
    if (lastIdRef.current) {
      target.searchParams.set('lastEventId', lastIdRef.current);
    }

    const es = new EventSource(target.toString(), { withCredentials: true });

    const track = (e: MessageEvent) => {
      if (e.lastEventId) lastIdRef.current = e.lastEventId;
      onMessageRef.current(e);
    };

    es.addEventListener('message', track);
    eventTypes.forEach((type) => es.addEventListener(type, track as EventListener));

    esRef.current = es;
  }, [url, eventTypes]);

  const disconnect = useCallback(() => {
    esRef.current?.close();
    esRef.current = null;
  }, []);

  useEffect(() => {
    if (!document.hidden) connect();

    const onVisibility = () => (document.hidden ? disconnect() : connect());
    document.addEventListener('visibilitychange', onVisibility);

    return () => {
      document.removeEventListener('visibilitychange', onVisibility);
      disconnect(); // clean up on unmount
    };
  }, [connect, disconnect]);
}

Usage:

// LiveFeed.tsx
import { useVisibilitySSE } from './useVisibilitySSE';

export function LiveFeed() {
  useVisibilitySSE('/api/feed', (e) => {
    const data = JSON.parse(e.data);
    // dispatch to store, update state, etc.
  }, ['update', 'delete']);

  return <div>...</div>;
}

Step 5 β€” Server-Side: Resume from lastEventId Permalink to this section

The client reconnect is only useful if the server can replay missed events. A minimal Node.js example using an in-memory ring buffer:

// server.js (Node.js / Express)
const RING_BUFFER_SIZE = 500;
const ringBuffer = []; // { id, data, type }

app.get('/api/events', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',   // disable nginx proxy buffering
  });
  res.flushHeaders();

  const lastId = req.query.lastEventId ?? req.headers['last-event-id'];
  if (lastId) {
    // Replay events the client missed while hidden
    const idx = ringBuffer.findIndex((e) => e.id === lastId);
    const missed = idx >= 0 ? ringBuffer.slice(idx + 1) : [];
    missed.forEach((e) => {
      res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${e.data}\n\n`);
    });
  }

  // Register this connection for future broadcasts
  addSubscriber(req, res);

  req.on('close', () => removeSubscriber(res));
});

For Redis pub/sub fan-out across multiple server nodes, store the ring buffer in a Redis sorted set keyed by event ID so any node can replay missed events.

Validation & Monitoring Permalink to this section

DevTools Verification Permalink to this section

  1. Open the page, verify the SSE stream appears in Network β†’ EventStream with events arriving.
  2. Switch to a different tab. Return to DevTools β€” the stream entry should disappear (connection closed) within ~100 ms of visibilitychange.
  3. Switch back to the tab. A new SSE request should appear with ?lastEventId=<id> in the URL.
  4. Check the Response Headers of the new request confirm Content-Type: text/event-stream.

curl Smoke Test for Resume Permalink to this section

# Confirm the server accepts lastEventId and replays missed events
curl -N \
  -H "Accept: text/event-stream" \
  "https://your-api.example.com/api/events?lastEventId=42" \
  2>/dev/null | head -20
# Expected: id: 43, id: 44, ... events replayed before live stream

Unit Test Stub (Vitest / jsdom) Permalink to this section

// useVisibilitySSE.test.ts
import { renderHook } from '@testing-library/react';
import { useVisibilitySSE } from './useVisibilitySSE';

it('closes EventSource when tab becomes hidden', () => {
  const handler = vi.fn();
  renderHook(() => useVisibilitySSE('/api/events', handler));

  const firstEs = globalThis.__lastEventSource; // injected by mock
  expect(firstEs.readyState).toBe(EventSource.OPEN);

  // Simulate tab hide
  Object.defineProperty(document, 'hidden', { value: true, configurable: true });
  document.dispatchEvent(new Event('visibilitychange'));

  expect(firstEs.close).toHaveBeenCalledOnce();
});

it('reopens with lastEventId when tab becomes visible', () => {
  // ... set lastEventId via fired message, then toggle visibility
});

Verification Checklist Permalink to this section

⚑ Production Directives

  • Set X-Accel-Buffering: no and Cache-Control: no-cache, no-transform on every SSE response β€” proxy buffering defeats the stream before your visibility logic even matters.
  • Keep a server-side ring buffer of at least 200-500 recent events keyed by ID so reconnecting clients get a replay, not a cold start.
  • Cap the ring buffer by size and TTL (e.g. 500 events or 5 minutes, whichever comes first) to bound memory usage on the server.
  • Emit a retry: field of 3000–5000 ms so that accidental disconnects (not visibility-driven ones) don't hammer the server on mobile networks.
  • Monitor the ratio of visibilitychange-driven disconnects vs error-driven disconnects in your analytics to distinguish intentional pauses from network instability.

Frequently Asked Questions Permalink to this section

Does closing EventSource on visibilitychange cause the browser's built-in retry to fire?

No. Calling es.close() sets readyState to CLOSED and cancels the internal reconnect timer. The browser will not attempt to reopen the connection on its own after an explicit close β€” that is your code's responsibility, which is why you must listen to visibilitychange and call connect() when the page becomes visible again.

What if the user switches tabs faster than the open/close cycle completes?

Guard connect() with a readyState check (if (es?.readyState !== EventSource.CLOSED) return). If the tab flips visible before the previous close() has fully propagated, the existing open connection is reused. Rapid tab toggling may create a brief gap, but the lastEventId replay ensures no events are missed once the final connect settles.

Should I use Page Visibility API or the beforeunload / pagehide events instead?

pagehide and beforeunload fire when the page is navigated away or closed β€” not when the user switches tabs. For tab-switching and mobile backgrounding, visibilitychange is the correct event. You may want both: visibilitychange for pausing live connections and pagehide for final cleanup before a bfcache entry is stored.

Does this work with HTTP/2 multiplexed SSE connections?

Yes. HTTP/2 multiplexes streams over a single TCP connection, but each SSE endpoint still opens its own logical stream (HEADERS + DATA frames). Closing the EventSource sends an RST_STREAM frame to the server, freeing that slot. Reconnecting opens a new stream β€” potentially over the same underlying TCP connection if it is still alive, which is faster than a full TLS handshake.

How do I handle the case where the server ring buffer has been purged by the time the tab comes back?

Detect this server-side: if the requested lastEventId is not in the ring buffer (too old), emit a special synthetic event β€” e.g. event: resync\ndata: {"reason":"buffer_overflow"}\n\n β€” so the client can trigger a full data fetch via REST rather than silently receiving an incomplete stream. See Error Handling & Reconnection UX for the client-side pattern.