Fixing a Memory Leak Caused by Event Listeners Not Removed in useEffect Cleanup
A single-page app gets slower and slower the longer a user navigates around it without a full page reload — memory usage climbs steadily in DevTools, and eventually a tab becomes sluggish or crashes outright. Nothing about any individual page looks wrong; the problem is components that attached a window or document event listener and never removed it when they unmounted, leaving every past instance of the component permanently reachable.
The Problem
A single-page app runs for an extended session without a full browser reload — a user navigates between routes, opens and closes modals, mounts and unmounts the same components repeatedly. Over time, the app becomes noticeably slower: scrolling gets janky, interactions lag, and the browser's memory profiler shows usage climbing steadily rather than stabilizing. Each individual page or component works correctly in isolation; the problem only becomes visible after enough navigation cycles have accumulated, which makes it easy to miss during normal development and testing sessions that rarely stay on one running tab for that long.
Why It Happens
An event listener attached to a long-lived target like window or document outlives the component that added it unless explicitly removed
window.addEventListener or document.addEventListener calls register a reference to the handler function on an object that persists for the entire page session, regardless of whether the React component that called it has since unmounted. If the component doesn't explicitly call removeEventListener on unmount, the listener — and everything it closes over, including potentially the entire component instance — stays referenced by window indefinitely.
A missing or incorrect useEffect cleanup function is the most common root cause
useEffect's cleanup function (the function returned from the effect callback) is specifically where listener removal belongs, and it's easy to either omit it entirely, or write it incorrectly — removing a different function reference than the one that was actually added, which silently fails to unregister anything while looking correct at a glance.
Each accumulated, un-removed listener keeps its entire closure scope alive, not just the handler function itself
A handler function closing over component state, props, or other local variables keeps all of that reachable from the global window object as long as the listener itself is registered. This means the memory cost of one leaked listener isn't just the function — it's everything that component's render captured in scope, which compounds specifically in components that hold larger amounts of local state or data.
The leak is invisible in a short-lived test but compounds specifically with repeated mount/unmount cycles
Mounting a component once, using it, and closing the tab never reveals this bug — the leaked memory is reclaimed when the page itself unloads. The symptom specifically requires many mount/unmount cycles within the same long-lived page session, which is exactly the usage pattern a single quick manual test or a short automated test run doesn't naturally exercise.
The Fix
1. Always return a cleanup function from useEffect that removes exactly what was added
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize); // same function reference as added
};
}, []);
Defining the handler function once and referencing that same function identity in both addEventListener and the cleanup's removeEventListener call is what actually makes the removal succeed — removing a newly-created function with the same name but a different reference silently does nothing.
2. Audit existing effects specifically for a missing or mismatched cleanup return
grep -rn "addEventListener" src/ | wc -l
grep -rn "removeEventListener" src/ | wc -l
# A meaningfully larger count for addEventListener than removeEventListener
# across the codebase is worth investigating directly
A rough count comparing how many places add a listener versus remove one won't catch every case (some listeners are genuinely meant to persist for the app's lifetime), but a significant imbalance is a reasonable signal to go audit specific components directly, rather than waiting for the symptom to surface in production memory profiling.
3. Use the browser's memory profiler to confirm detached DOM nodes and retained listeners directly
Chrome DevTools → Memory tab → Heap snapshot
Take a snapshot, perform several mount/unmount cycles of the suspect component,
take a second snapshot, and use "Comparison" view to see what grew
Look specifically for "Detached" DOM tree nodes retained despite being unmounted
Comparing heap snapshots before and after repeated mount/unmount cycles directly shows which objects are accumulating rather than being garbage collected, and a detached DOM subtree still being retained is a strong, specific signal pointing at exactly this class of leak rather than a general "the app feels slow" symptom.
4. Prefer a library abstraction that handles cleanup automatically where available
import { useEventListener } from "usehooks-ts"; // or an equivalent custom hook
function MyComponent() {
useEventListener("resize", () => setWidth(window.innerWidth));
// cleanup handled internally by the hook — nothing to remember to do manually
}
A well-tested shared hook that wraps the add/remove pair internally removes the specific human error of forgetting or mismatching the cleanup at every individual call site, centralizing the correct behavior in one place rather than needing to get it right every time a new component adds a global listener.
Why This Works
Each fix addresses a different point where a listener can fail to be removed correctly. Ensuring the cleanup function references the exact same handler identity makes the removal actually succeed rather than silently failing; auditing the add/remove ratio surfaces likely culprits before they show up as a production symptom; using the memory profiler directly confirms the specific mechanism (detached nodes, retained listeners) rather than guessing from a vague slowness report; and a shared hook abstraction removes the recurring opportunity for this exact mistake across the whole codebase.
Conclusion
A single-page app slowing down over a long session isn't a vague performance problem — it's very often event listeners attached to window or document that outlive the component that added them, each one keeping that component's entire closure scope reachable. Always return a cleanup function that removes the exact same handler reference that was added, audit the codebase for a significant add/remove imbalance, confirm the mechanism directly with the browser's memory profiler, and consider a shared hook abstraction that handles the cleanup automatically so it can't be forgotten on a per-component basis.
