Preventing memory leaks in React useEffect WebSockets #

You opened the Chrome DevTools Memory tab, took two heap snapshots a minute apart, and the delta keeps climbing. Closed WebSocket objects with readyState === 3 are retained by closure scopes, the Network tab shows multiple 101 Switching Protocols per component lifecycle, and your console is full of “Can’t perform a React state update on an unmounted component.” If you mount and unmount a chat panel a few dozen times and watch sockets accumulate instead of stabilize, you have a useEffect teardown problem — almost always provoked by React 18+ StrictMode mounting your effect twice. This page explains the exact mechanism and gives you one teardown pattern that survives StrictMode, fast remounts, and url changes.

Root cause #

A useEffect that opens a socket has exactly one chance to release it: the cleanup function it returns. Two failure shapes break that contract.

First, an absent or partial cleanup. When a component unmounts or the effect’s dependencies change, React invokes the cleanup synchronously before re-running the effect body. If you never call ws.close() there, the underlying TCP connection stays in ESTABLISHED, the browser keeps the WebSocket object alive, and every inbound frame fires onmessage into a handler that closes over a setState from a component instance React has already discarded. That closure is the retainer — it pins the old component’s state, props, and any DOM nodes they reference, so the garbage collector can never reclaim them. The visible side effect is the “update on an unmounted component” warning; the invisible one is monotonic heap growth.

Second, StrictMode’s intentional double-invoke. In development, React 18 and 19 mount each component, run effects, immediately run cleanup, then run effects again — a deliberate probe for non-idempotent setup. If your first cleanup does not fully close the socket and null its handlers, the second mount opens a connection while the first is still draining, leaving two live sockets bound to the same endpoint. This is not a dev-only artifact: the same defect surfaces in production whenever a url dependency changes faster than a half-open socket finishes its closing handshake. The browser caps concurrent connections, so leaked sockets eventually starve new ones and reconnect storms begin — the same instability the auto-reconnection strategies on the backend are designed to absorb, except here the client is generating the churn.

The fix has to be deterministic across all of these: it must close exactly the socket the effect created (not whatever wsRef.current happens to point at after a race), and it must detach handlers so a late onclose cannot trigger reconnect logic during an intentional unmount.

StrictMode double-mount socket leak Without full cleanup a remount opens a second socket; with cleanup the first socket closes before the second opens. Leaky cleanup Deterministic cleanup mount A: open mount B: open 2 sockets alive, A retained mount A: open cleanup: close A mount B: open 1 socket alive, A reclaimed

Resolution #

Capture the socket in a local const so the cleanup closes the exact instance the effect opened, mirror it into a useRef for imperative send access, and null every handler before closing so a trailing onclose cannot re-enter your reconnect path. This is the same teardown discipline that a reusable useWebSocket React hook should encapsulate so call sites never reimplement it.

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

const NORMAL_CLOSURE = 1000; // RFC 6455 code for an intentional, clean close

export function useSafeWebSocket(url: string) {
const wsRef = useRef<WebSocket | null>(null); // imperative handle for send()
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<string | null>(null);

useEffect(() => {
const ws = new WebSocket(url); // local const: the exact socket this run owns
wsRef.current = ws;
let disposed = false; // guards setState after teardown began

ws.onopen = () => { if (!disposed) setIsConnected(true); };
ws.onclose = () => { if (!disposed) setIsConnected(false); };
ws.onerror = (e) => console.error('WS error', e);
ws.onmessage = (e) => { if (!disposed) setLastMessage(e.data); };

return () => {
disposed = true; // stop any in-flight handler from calling setState
ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; // break closure retainers
// close() is a no-op on CLOSED in spec but throws in some polyfills, so gate it
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(NORMAL_CLOSURE, 'effect cleanup'); // closes THIS socket, not wsRef.current
}
if (wsRef.current === ws) wsRef.current = null; // only clear the ref if it still points here
};
}, [url]); // re-runs (and cleans up) whenever url changes

const sendMessage = useCallback((data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data); // ignore sends while CONNECTING/CLOSED
}
}, []);

return { isConnected, lastMessage, sendMessage };
}

The two details that make this StrictMode-safe are the local ws const and the disposed flag. Closing over ws instead of wsRef.current guarantees each cleanup closes the socket its own effect run created, even when a remount has already overwritten the ref. The disposed flag closes the race where a CONNECTING socket fires onopen after teardown — without it you would still get a state update on an unmounted component. Gating close() on readyState avoids throwing in older browsers and polyfills where closing an already-CLOSED socket is not a clean no-op.

Operational checklist #

The retention chain is short and always runs the same direction, which is why it is so easy to miss by reading the code: you think the component owns the socket, and in memory terms the socket owns the component.

What keeps an unmounted component alive A live socket holds its listener set, which holds the handler closure, which captures the unmounted component's scope — so the component cannot be collected while the socket lives. What keeps an unmounted component alive Live WebSocket reachable while the connection is open root side Listener set internal to the socket, holds every handler Handler closure captures props, setters, context Unmounted component tree retained, still receiving updates the leak Cut the third layer and everything below becomes collectable immediately
One un-removed listener retains an entire subtree. That is the whole mechanism.
Retained message handlers by navigation count Without cleanup, each visit to a real-time route leaves another handler registered on the socket, so retained handlers grow linearly with navigation count; with correct teardown the count returns to zero every time. Retained message handlers by navigation count one mount and unmount per visit to the real-time route Leaked handlers With cleanup 0 10 20 30 1 visit 5 5 visits 10 10 visits 25 25 visits
Linear growth with navigation is the signature — and it is why the bug is invisible in development and obvious after an hour of real use.

FAQ #

Why does StrictMode open two WebSocket connections in development? #

React 18+ StrictMode deliberately runs setup → cleanup → setup once on mount to surface effects that are not idempotent. If your cleanup does not fully close the first socket, the second setup opens another, so you briefly see two 101 Switching Protocols. With the cleanup above, the first socket is closed before the second opens, so you end with exactly one. This only happens in development; production mounts the effect once.

Should I close the socket in the cleanup or rely on garbage collection? #

Always close it explicitly. A WebSocket holds an open TCP connection that the GC will not reclaim while any handler closure is reachable, and the connection itself consumes a server file descriptor regardless of client-side GC. Calling close() releases both deterministically.

Why use a local const instead of wsRef.current inside cleanup? #

Because a fast remount or a url change can overwrite wsRef.current before the previous cleanup runs. Closing over a local const ws guarantees each cleanup closes the precise socket its own effect created, which is what prevents the StrictMode double-mount leak.

Does nulling the handlers actually matter if I already call close()? #

Yes. close() triggers an asynchronous closing handshake, and onclose can fire after teardown — if it still references your component’s state or reconnect logic, it both retains memory and can spawn an unwanted reconnect. Nulling the handlers severs the closure retainers immediately and disarms that trailing callback.

How do I confirm the leak is gone? #

In DevTools, take a heap snapshot, mount/unmount the component 30–50 times, force GC, then take a second snapshot and filter by WebSocket in the comparison view. Retained-count should be flat. Pair this with a unit test that asserts readyState === CLOSED for every constructed socket after unmount().

Does closing the socket remove its listeners? #

A closed socket that nothing else references becomes collectable along with its listeners — but “nothing else references” is the part that usually fails. A ref, a pending reconnect timer, or an entry in a connections map keeps it alive, and with it every handler closure. Removing listeners explicitly costs one line and makes cleanup correct regardless of what else happens to hold the socket.

Why does the leak look twice as bad in development? #

React StrictMode runs every effect as mount, cleanup, mount so that incomplete teardown produces a visible duplicate instead of a silent one. If your handler count doubles in development and grows steadily in production, both are the same bug — StrictMode is showing you the leak at twice the rate rather than causing it.

Back to Memory Leak Prevention in WebSocket React Apps