Stale Closures: Why Your WebSocket Handler Sees State From the Past
There's a class of React bug where the code reads correctly, reviews correctly, and behaves wrongly in a way users describe as "haunted." The app sort of reacts to what they did three interactions ago. Nine times out of ten, that's a stale closure: a long-lived function that captured state at creation time and has been reading from the past ever since.
Closures capture values, not variables
Every render of a function component creates fresh local bindings. A callback you create during render closes over that render's values. If the callback lives longer than the render — socket handler, interval, event listener, anything registered once — it keeps reading those frozen values forever.
Here's the distilled version of our bug:
function VitalsMonitor({ siteId }: { siteId: string }) {
const [threshold, setThreshold] = useState(120);
const [readings, setReadings] = useState<Reading[]>([]);
useEffect(() => {
const ws = new WebSocket(`${WS_URL}/sites/${siteId}/vitals`);
ws.onmessage = (event) => {
const reading = JSON.parse(event.data);
setReadings((prev) => [...prev.slice(-99), reading]);
if (reading.heartRate > threshold) {
// ❌ `threshold` is whatever it was when this effect ran: 120. Forever.
raiseAlert(reading);
}
};
return () => ws.close();
}, [siteId]); // threshold intentionally omitted "to avoid reconnecting" — the trap
// ...
}
Whoever wrote it knew adding threshold to the dependency array would tear down and reopen the WebSocket on every slider change — genuinely bad for a medical monitoring stream. So they omitted it, the linter warning got suppressed, and the handler became a time capsule.
Fix 1: The ref escape hatch (right for this case)
When a long-lived subscription needs current state without re-subscribing, split the concern: the connection depends on connection identity; the handler reads live values through a ref.
function VitalsMonitor({ siteId }: { siteId: string }) {
const [threshold, setThreshold] = useState(120);
const thresholdRef = useRef(threshold);
thresholdRef.current = threshold; // updated every render, read at event time
useEffect(() => {
const ws = new WebSocket(`${WS_URL}/sites/${siteId}/vitals`);
ws.onmessage = (event) => {
const reading = JSON.parse(event.data);
setReadings((prev) => [...prev.slice(-99), reading]);
if (reading.heartRate > thresholdRef.current) {
// ✅ reads the value at message time, not at subscribe time
raiseAlert(reading);
}
};
return () => ws.close();
}, [siteId]); // honest deps: the socket only depends on siteId
}
This is exactly the pattern React's experimental useEffectEvent formalizes — an "event" function that always sees latest props and state but never triggers re-subscription. Until it's stable in your React version, the ref pattern is the reliable spelling. Wrap it once and name it:
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
Fix 2: Functional updates (when the state you need is the state you're setting)
Notice setReadings((prev) => ...) above was never buggy. Functional updates don't read the closure at all — React hands you the current value. A shocking number of stale-closure bugs dissolve by converting setCount(count + 1) into setCount((c) => c + 1). The interval counter that sticks at 1 is the canonical example:
useEffect(() => {
const id = setInterval(() => {
setElapsed((e) => e + 1); // ✅ no dependency on `elapsed` needed at all
}, 1000);
return () => clearInterval(id);
}, []);
Fix 3: Restructure so the closure is short-lived
The deepest fix is often to stop having long-lived closures over state. In the dashboard rewrite, we moved threshold checking out of the socket handler entirely — the handler only appends readings; a plain render-time derivation decides what's alerting:
const activeAlerts = readings.filter(
(r) => r.receivedAt > lastAckAt && r.heartRate > threshold
);
Render-time code can't be stale — it re-runs with fresh values by construction. The socket handler shrank to one line, and an entire category of bug became unrepresentable. (Side-effectful alerting — sounds, pages to the on-call nurse — subscribed to activeAlerts changes in a small dedicated effect.)
Where stale closures hide
After years of reviewing React code across banking, pharma, and design-tools teams, my checklist of places to look:
setInterval/setTimeoutcallbacks- WebSocket / EventSource / SDK event handlers registered in a mount-only effect
window.addEventListenerin effects with[]- Debounced/throttled functions created with
useMemo(() => debounce(...), []) - Callbacks stored in refs or passed to imperative libraries (maps, charts, editors) at init
useCallbackwith deliberately-narrowed deps, passed into anything that caches it
The QA ticket closed with a two-line diff. The design review that followed moved four other dashboards off the same pattern before they could file their own haunted-app tickets — that's the part I actually count as the fix.
Keep Reading
05 Jul 2026
Next.js Served Yesterday's Prices: Untangling the Four Caching Layers
04 Jul 2026
Hydration Mismatches: Debugging React's Most Passive-Aggressive Error
08 Jul 2026
The Search Box That Showed the Wrong Results: Effect Race Conditions
07 Jul 2026
"Functions Cannot Be Passed to Client Components": Surviving the RSC Boundary