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.
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 #
- Every
useEffectthat constructs aWebSocketreturns a cleanup that callsclose() - All four handlers (
onopen,onmessage,onerror,onclose) are nulled inside cleanup beforeclose() - A
disposed/cancelledflag guards everysetState -
close()is gated onreadyState === OPEN || CONNECTING - Verified in StrictMode (dev) that mounting and unmounting the component leaves zero sockets in
readyState !== CLOSED - Heap snapshots taken a minute apart over repeated mount/unmount show no retained
WebSocket -
eslint-plugin-react-hooksexhaustive-depsis enabled sourl - A CI test asserts that after
unmount()every constructed socket reportsreadyState === WebSocket.CLOSED
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().
Related #
- Memory Leak Prevention — the full diagnostic workflow for detached nodes, retained closures, and leaked sockets.
- React WebSocket Custom Hooks — package this teardown inside a reusable
useWebSockethook. - Auto-Reconnection Strategies — so client churn from leaked sockets does not become a reconnect storm.
- Frontend WebSocket State Hooks & UI Patterns — the broader set of React/Vue real-time state patterns.