Preventing EventSource Memory Leaks in React Permalink to this section

Part of React EventSource Hooks & State.

An EventSource connection opened inside a useEffect stays open indefinitely unless you explicitly call .close(). In a React app this manifests as one open TCP connection per mount — and because React 18’s StrictMode mounts every component twice in development, you immediately see two connections where you expect one. Leave it untreated and a multi-page SPA accumulates dozens of idle connections, exhausts the browser’s per-origin connection limit (6 for HTTP/1.1), and pins RAM because each connection keeps its closure variables and queued microtasks alive.

Symptom & Developer Intent Permalink to this section

You’re building a live-feed UI — a notification tray, a log tail, an AI token stream. The intent is one EventSource per mounted component, reconnecting automatically if the server drops the stream, and cleaning up when the user navigates away.

The observable symptoms of a leak:

  • Chrome DevTools → Network → WS/SSE tab shows connections accumulating without a matching “cancelled” entry after navigation.
  • performance.memory.usedJSHeapSize climbs monotonically across route transitions.
  • In development (StrictMode), the server receives two simultaneous GET /stream requests from a single browser tab within milliseconds of page load.
  • Your event handler fires with stale state — the value captured at mount time rather than the current value — because the effect’s closure snapshot is never refreshed.
  • console.error: Can't perform a React state update on an unmounted component (React 17 and below; silenced in 18 but the update still occurs, potentially writing to garbage state).

Root Cause Analysis Permalink to this section

No cleanup function in useEffect Permalink to this section

EventSource opens a persistent HTTP connection using the Event ID & Retry Mechanism handshake. The browser does not close it when the component unmounts; that is the caller’s responsibility. A useEffect with no return value leaves the connection open:

useEffect(() => {
  const es = new EventSource('/stream'); // leak: never closed
  es.onmessage = (e) => setState(e.data);
}, []);

StrictMode double-mount Permalink to this section

React 18 StrictMode runs every effect twice: mount → cleanup → mount. If the cleanup is missing or incomplete, the first mount’s EventSource is never closed before the second mount opens another. In production builds StrictMode is inactive, so leaks appear only in staging or after cumulative navigation events.

Stale closure over state Permalink to this section

When the effect runs, it captures the values of every variable in scope at that instant. If deps is [] (run once), callbacks like onmessage reference the initial render’s state. Subsequent state changes are invisible inside the handler:

const [count, setCount] = useState(0);

useEffect(() => {
  const es = new EventSource('/stream');
  es.onmessage = () => {
    // `count` is always 0 — stale closure
    console.log('current count', count);
  };
}, []); // count not in deps

Effect dependency churn Permalink to this section

Adding url or options objects to the deps array causes the effect to re-run on every render if those references are not stable. Each re-run opens a new EventSource before (or instead of) closing the previous one.

Step-by-Step Resolution Permalink to this section

Step 1 — Always return a cleanup function Permalink to this section

The minimum correct pattern closes the connection in the effect’s cleanup:

import { useEffect } from 'react';

function useLiveData(url) {
  useEffect(() => {
    const es = new EventSource(url);
    es.onmessage = (e) => console.log(e.data);

    return () => {
      es.close(); // runs on unmount AND before re-run
    };
  }, [url]);
}

EventSource.close() transitions the connection to CLOSED (readyState 2) and stops all reconnection attempts. It is idempotent — calling it on an already-closed source is a no-op.

Step 2 — Survive StrictMode double-mount with a guard flag Permalink to this section

StrictMode’s mount→cleanup→mount sequence will call close() on the first instance and then open a second. That is the correct behaviour — your cleanup must be fast enough that the second mount’s connection is the one that survives:

import { useEffect, useRef } from 'react';

function useLiveData(url, onMessage) {
  const onMessageRef = useRef(onMessage);
  // keep the ref current without re-running the effect
  useEffect(() => { onMessageRef.current = onMessage; });

  useEffect(() => {
    let active = true; // guard against StrictMode ghost mount
    const es = new EventSource(url);

    es.onmessage = (e) => {
      if (active) onMessageRef.current(e);
    };

    es.onerror = (e) => {
      if (es.readyState === EventSource.CLOSED) {
        // browser will retry; nothing to do unless you want custom backoff
      }
    };

    return () => {
      active = false;
      es.close();
    };
  }, [url]); // url change → close old, open new
}

The active flag prevents the ghost mount’s handler from dispatching events after close() was called. onMessageRef is a stable ref updated on every render so the handler always sees the latest callback without needing to be in deps.

Step 3 — Fix stale closures with a ref instead of state in the handler Permalink to this section

Any value you read inside an onmessage callback that can change over time must be accessed through a ref, not a closure variable:

import { useEffect, useRef, useState } from 'react';

function useCounter(url) {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // Mirror state into ref synchronously after every render
  useEffect(() => { countRef.current = count; });

  useEffect(() => {
    const es = new EventSource(url);

    es.onmessage = (e) => {
      // reads the ref — always current, never stale
      const newCount = countRef.current + Number(e.data);
      setCount(newCount);
    };

    return () => es.close();
  }, [url]);

  return count;
}

Alternatively, use the functional form of setState which receives the previous value without capturing it in a closure:

es.onmessage = (e) => {
  setCount((prev) => prev + Number(e.data)); // no stale closure
};

The functional updater is the simpler fix when you only need the previous state value. Use a ref when you need access to other hooks’ values (e.g., the current Redux store slice or a sibling useRef).

Step 4 — Stabilise dependency objects with useMemo / useCallback Permalink to this section

Passing object or function literals directly as props leads to effect churn:

// BAD: new object every render → effect fires every render
<LiveFeed options={{ withCredentials: true }} />

function LiveFeed({ options }) {
  useEffect(() => {
    const es = new EventSource('/stream', options); // re-runs constantly
    return () => es.close();
  }, [options]); // unstable reference
}

Fix with useMemo at the call site or inside the hook:

import { useEffect, useMemo } from 'react';

function LiveFeed({ withCredentials }) {
  // primitive dep — stable across renders unless the boolean changes
  const options = useMemo(
    () => ({ withCredentials }),
    [withCredentials]
  );

  useEffect(() => {
    const es = new EventSource('/stream', options);
    return () => es.close();
  }, [options]);
}

For URLs built from multiple pieces, compute them with useMemo so the string reference is stable:

const url = useMemo(
  () => `/stream?topic=${topic}&since=${lastId}`,
  [topic, lastId]
);

Step 5 — Integrate with the Last-Event-ID for reconnection safety Permalink to this section

If your handler accumulates state (e.g., a message list), you need to track the last received event.lastEventId so that on reconnect the server can replay missed events. Store it in a ref, not state, to avoid triggering the effect:

import { useEffect, useRef, useState } from 'react';

function useEventStream(baseUrl) {
  const [messages, setMessages] = useState([]);
  const lastIdRef = useRef('');

  useEffect(() => {
    // Append last ID as a query param for servers that prefer it,
    // or omit — the browser sends Last-Event-ID header automatically
    const es = new EventSource(baseUrl);

    es.onmessage = (e) => {
      if (e.lastEventId) lastIdRef.current = e.lastEventId;
      setMessages((prev) => [...prev, e.data]);
    };

    return () => es.close();
  }, [baseUrl]);

  return messages;
}

The browser’s native EventSource already sends Last-Event-ID on reconnect per the WHATWG spec reconnection algorithm, so you do not need to build that yourself — just make sure you are not recreating the EventSource instance in application code when you intend a native reconnect.

Validation & Monitoring Permalink to this section

DevTools network inspection Permalink to this section

  1. Open Chrome DevTools → Network tab → filter by Fetch/XHR or type eventsource in the filter box.
  2. Mount and unmount the component (navigate away and back).
  3. Confirm each unmount produces a (cancelled) entry against the stream URL. If connections accumulate without cancellations, the cleanup is missing.

Heap snapshot diff Permalink to this section

  1. DevTools → Memory → take a Heap Snapshot before mounting.
  2. Mount the component, wait for a few events, unmount.
  3. Take a second snapshot. Click Comparison view, filter by EventSource. The delta should be zero retained EventSource objects.

Unit test with fake timers (Vitest / Jest + React Testing Library) Permalink to this section

import { renderHook, act, cleanup } from '@testing-library/react';
import { vi, it, expect, afterEach } from 'vitest';
import { useLiveData } from './useLiveData';

afterEach(cleanup);

it('closes EventSource on unmount', () => {
  const closeSpy = vi.fn();
  const mockEs = {
    close: closeSpy,
    readyState: 0,
    onmessage: null,
    onerror: null,
  };
  vi.spyOn(global, 'EventSource').mockImplementation(() => mockEs);

  const { unmount } = renderHook(() => useLiveData('/stream', () => {}));
  unmount();

  expect(closeSpy).toHaveBeenCalledTimes(1);
});

it('opens exactly one connection under StrictMode double-mount', () => {
  const constructorSpy = vi.spyOn(global, 'EventSource').mockImplementation(
    () => ({ close: vi.fn(), onmessage: null, onerror: null, readyState: 0 })
  );

  // StrictMode: mount, unmount, mount
  const { unmount } = renderHook(() => useLiveData('/stream', () => {}));
  unmount();
  renderHook(() => useLiveData('/stream', () => {}));

  // Two calls expected — StrictMode opens two; only one survives
  expect(constructorSpy).toHaveBeenCalledTimes(2);
});

curl round-trip check Permalink to this section

Confirm the server honours Last-Event-ID on reconnect — a prerequisite for safe cleanup-and-reopen cycles:

# First connection — grab an event ID
curl -N -H "Accept: text/event-stream" https://example.com/stream \
  | head -20

# Reconnect from a known ID
curl -N \
  -H "Accept: text/event-stream" \
  -H "Last-Event-ID: 42" \
  https://example.com/stream

If the server replays events from ID 43 onward, your idempotent event IDs and reconnection logic are correct.


⚡ Production Directives

  • Always return () => es.close() from every useEffect that opens an EventSource — no exceptions.
  • Use a ref wrapper (onMessageRef) for callbacks so you never have to include them in deps and never get stale closures.
  • Prefer the functional setState(prev => …) updater pattern inside onmessage to eliminate the most common stale-state bug.
  • Stabilise options objects and computed URLs with useMemo before passing them as effect dependencies.
  • Run a heap snapshot comparison in your CI smoke suite to catch regressions before they reach production.

Frequently Asked Questions Permalink to this section

Why do I see two SSE connections in development but only one in production?

React 18 StrictMode, active only in development, intentionally mounts every component twice (mount → cleanup → mount) to help surface missing cleanups. If your useEffect lacks a cleanup that calls es.close(), the first mount's connection is never closed before the second mount opens another. In production StrictMode is inactive, so only one mount occurs — but the leak is still present and will accumulate across navigations.

Is it safe to call EventSource.close() inside useEffect cleanup if the connection is already closed?

Yes. EventSource.close() is a no-op when readyState is already CLOSED (2). The spec says the method must do nothing if the connection is not open. You do not need to guard with an if (es.readyState !== EventSource.CLOSED) check, though doing so is harmless.

Should I recreate the EventSource myself to implement reconnection backoff, or rely on the browser?

Rely on the browser for standard reconnection. The WHATWG spec's reconnection algorithm sends Last-Event-ID and waits retry milliseconds (default 3 000 ms, overridable server-side). Only implement custom backoff if you need exponential delays or circuit-breaking. If you do recreate the connection yourself, store the last event ID in a ref and pass it as a query parameter — do not store it in state, or you will trigger a new effect cycle on every event.

What happens to in-flight setState calls after close() is called?

Calling es.close() stops the browser from dispatching further events, but any microtasks already queued before close() was called may still fire. The active guard flag in Step 2 prevents those stale callbacks from calling setState after unmount. Without the guard, React 18 silently drops the update (no warning), but React 17 and below log a console error.

Does this advice apply to fetch-based SSE (ReadableStream) as well?

Yes, with a different cleanup mechanism. For fetch + ReadableStream patterns you call controller.abort() (via AbortController) in the cleanup, and cancel the reader with reader.cancel(). The stale closure, double-mount, and dep-stability issues are identical.