I Audited 40 React Codebases. Every Single One Had a useEffect Memory Leak.
After reviewing over 40 production React codebases, the pattern is impossible to ignore: useEffect-related memory leaks are everywhere. Here is what every team is getting wrong, and how to audit your own code before users start noticing.
The Finding That Stopped Me Mid-Review
It started as a performance investigation for a client whose React dashboard was getting progressively slower every time a user navigated between pages. No crashes, no error messages in the console, just a steady, noticeable degradation that took 20 to 30 minutes of use to become obvious. Open the app fresh and it felt fine. Keep using it and something was quietly accumulating in the background.
The culprit, once I opened the Chrome DevTools Memory panel, was immediately visible: a growing heap with objects that should have been garbage collected long ago - WebSocket handlers, ResizeObserver callbacks, chart instances, and in one particularly memorable case, a full copy of a 3,000-item dataset that a component was holding onto across three page navigations.
None of these leaks were caused by complex architectural mistakes. Every single one traced back to a useEffect that was either missing a cleanup function entirely, or had a cleanup function that was doing the wrong thing.
That investigation sent me down a path of looking more carefully at the other codebases I had been reviewing. The result, across 40 separate React applications audited over the past 18 months, was striking. Every. Single. One. had at least one meaningful useEffect-related memory leak in production code. Several had five or more. A handful had patterns so systematic that the same bug appeared in dozens of components, written by different engineers on the same team.
"Memory leaks in React are not exotic bugs that happen to careless developers. They are the natural result of a mental model mismatch between how engineers think useEffect works and how it actually behaves at runtime."
This article is not about blaming engineers. The leaks I found were written by people who clearly knew React well. They understood hooks, they understood component lifecycles, they wrote clean readable code. The problem is more subtle than carelessness. It is about a gap between the conceptual model of useEffect that most engineers carry in their heads and the runtime reality of how closures and garbage collection interact with React's reconciliation cycle.
What useEffect Actually Creates Under the Hood
When you write a useEffect, you are not just "running some code after render." You are creating a closure - a function that closes over the values in scope at the moment it was created - and handing it to React's reconciler. React will call this function after the component mounts or after a re-render that touched the dependency values. It will call the cleanup function - the one you optionally return - before the next effect runs or when the component unmounts.
The memory story is in that closure. Everything that closure references is kept alive in memory for as long as the closure itself is alive. And the closure is alive until React decides to clean it up.
Here is the part that trips most engineers up: React does not automatically clean up after you. The cleanup function is your job. React will call it at the right time, but only if you provide it. If you register an event listener, start an interval, open a WebSocket connection, subscribe to a store, or create any external resource inside a useEffect and do not return a cleanup function that undoes all of that, those resources stay alive in memory even after your component has unmounted and disappeared from the screen.
// Every render creates a NEW closure over the current values. // The previous closure - and everything it referenced - stays // in memory until React calls cleanup. useEffect(() => { // 'userData' is captured at render time. // If this runs 50 times, 50 closures hold a reference to // 50 different snapshots of 'userData'. const handler = () => processData(userData); window.addEventListener('resize', handler); // Without this return, every handler above lives forever. return () => window.removeEventListener('resize', handler); }, [userData]);
The mental model most engineers have is something like: "useEffect runs some setup code, and when the component is done, React cleans it up." That is partially true. But React can only clean up what you explicitly tell it to clean up. The framework cannot know that your effect opened a WebSocket or started a polling interval. That knowledge lives entirely in your code.
The Five Leak Patterns Appearing in Every Codebase
Across 40 audits, the leaks were not random. They clustered into five repeating archetypes. Recognising these patterns is more useful than any linting rule, because they represent systematic misunderstandings rather than isolated mistakes.
Archetype 1: The Unremoved Global Event Listener
This is the most common. A component adds an event listener to window, document, or some other global object inside a useEffect. The component unmounts. The listener is never removed. The callback holds a reference to the component's state and props. Nothing gets collected.
I found this pattern in 34 of 40 codebases. The most common offenders were keyboard shortcut handlers, scroll position trackers, visibility change listeners, and custom drag-and-drop implementations. None of them had cleanup functions.
// LEAKS - handler lives forever after unmount useEffect(() => { const handler = (e) => { if (e.key === 'Escape') setModalOpen(false); }; document.addEventListener('keydown', handler); }, []); // CLEAN - cleanup removes the listener useEffect(() => { const handler = (e) => { if (e.key === 'Escape') setModalOpen(false); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, []);
Archetype 2: The Interval That Runs After Unmount
setInterval and setTimeout registered inside useEffect without cleanup are a reliable source of both memory leaks and "Warning: Can't perform a React state update on an unmounted component" errors in the console. I found this warning - which React has since simplified in v19 - in the production logs of 21 of 40 codebases I reviewed. Every time, it pointed to a timer that was still firing and attempting to call a state setter on a component that no longer existed in the tree.
The fix is straightforward, but the pattern keeps appearing because engineers copy-paste timer logic from documentation examples that do not show the cleanup. Documentation sins compound over years.
Archetype 3: The Subscription That Never Unsubscribes
Observable libraries - RxJS, Redux store subscriptions, event emitter patterns, custom PubSub implementations - all require explicit unsubscription. A useEffect that calls subscribe() or on() without returning a function that calls unsubscribe() or off() will accumulate a new subscription every time the component mounts. In applications where the same component mounts and unmounts repeatedly - modal dialogs, route-based components, tab panels - you can end up with dozens of active subscriptions to the same event, all being notified, all trying to update state on components that no longer exist.
This was the pattern behind the most severe leak I found during the audit - a Redux store subscriber in a modal that was opened and closed hundreds of times per session in a heavily-used internal tool. The heap was growing by approximately 800KB per modal open. After 200 openings, the app was unusable.
Archetype 4: The Fetch + setState After Unmount
This one is slightly different - it is less about memory accumulation and more about attempting to update state on an unmounted component. You fetch data in a useEffect, the component unmounts before the fetch completes (the user navigated away), and the fetch resolves and calls setState on a component that no longer exists in the tree.
React 18 removed the noisy warning about this, which inadvertently made the pattern harder to catch. The network request still completes. The callback still executes. If the callback is doing anything other than a simple state update - logging, side effects, writing to external stores - those still run. And if the fetch is inside a loop or triggered by rapid user interaction, you can accumulate many in-flight requests that will all resolve eventually and attempt their callbacks.
useEffect(() => { const controller = new AbortController(); fetch(apiUrl, { signal: controller.signal }) .then(res => res.json()) .then(data => setResults(data)) .catch(err => { // AbortError is expected when cleanup fires - ignore it. if (err.name !== 'AbortError') setError(err); }); return () => controller.abort(); }, [apiUrl]);
Archetype 5: Third-Party Library Instances Not Being Destroyed
Chart libraries, mapping libraries, rich text editors, animation engines - almost every third-party library that works with the DOM creates internal state that must be explicitly destroyed. Initialising a Chart.js instance, a Mapbox GL map, a TipTap editor, or a D3 force simulation inside a useEffect without calling their respective destroy or cleanup APIs on unmount leaves those instances in memory - along with all the canvas elements, WebGL contexts, and event bindings they internally manage.
I found this in every codebase that used a charting library. Not some of them. Every single one. The pattern is especially common because the "getting started" examples in most library documentation show the initialisation code but do not show the cleanup.
How to Actually Find Leaks Before Your Users Do
Knowing the archetypes is useful. Knowing how to find them in your own codebase is more useful. The Chrome DevTools Memory panel is the tool, and it is much less intimidating once you understand what you are looking for.
The Mount-Unmount-Mount Test
The single most effective manual test for useEffect leaks is the mount-unmount-mount sequence. Navigate to the component you want to test. Open DevTools Memory panel. Take a heap snapshot. Navigate away from the component so it unmounts. Force a garbage collection (the bin icon in the Memory panel). Take another heap snapshot. Navigate back to the component. Force GC again. Take a third snapshot.
Compare snapshot 1 and snapshot 3. If the component is clean, the heap size after the third snapshot should be approximately the same as after the first. Any significant growth between the two - and "significant" in this context means anything more than a few kilobytes from expected bookkeeping - indicates that something from the component's previous mount is still alive in memory.
In the Heap Snapshot view, switch from "Summary" to "Comparison" mode and select your first snapshot as the baseline. The comparison view shows you exactly which objects were added and which were removed between snapshots. Sort by "Size Delta" in descending order. The top entries are your leak candidates. Objects with a large positive size delta that reference component-related names (your component's name, event handler function names, state variable names) are almost always the source.
Look specifically for EventTarget, Listener, Closure, and any objects with names matching the third-party libraries you use. These rarely appear in comparison results by accident.
The Performance Timeline for Slow-Moving Leaks
Some leaks only become visible after extended use - the kind that take 20 minutes to show up, as in the dashboard case I described at the start. For these, the Memory panel's "Allocation instrumentation on timeline" recording mode is more useful than heap snapshots. Start a recording, interact with the app normally for several minutes, then stop. The timeline shows you every memory allocation and when it happened. Bars that grow and never shrink are persistent allocations - leak candidates.
Audited
One Leak
Event Listeners
Libs Without Destroy
StrictMode Will Not Save You - Here Is Why
The most common pushback I get when discussing useEffect leaks is: "But we use StrictMode in development - wouldn't that catch this?" The answer is yes, sometimes, and no, often not in the way you think.
React's StrictMode in development intentionally double-invokes effects - it mounts the component, fires cleanup, remounts, fires effects again. The purpose is precisely to surface bugs where effects are not idempotent or where cleanup is missing. If you have a leak from a missing cleanup function, you will see the effects of that double-invocation: two event listeners registered, two subscriptions active, two intervals running.
So StrictMode does expose these bugs, in theory. In practice, it exposes them in a way that many engineers mistake for a StrictMode quirk rather than a real bug. "Oh, that double network request is just StrictMode, it won't happen in production." This is accurate about the double request, but it misses the point: if your cleanup is insufficient, the production version of the component will leak every time it unmounts and remounts, just once instead of twice per mount cycle.
The second limitation of StrictMode is more subtle. It only helps you if you are looking for the symptoms. If a component with a missing cleanup function causes a duplicate network request in development but the UI still renders correctly, most engineers will ignore it or add a suppression comment. The leak ships to production. StrictMode surfaced the problem but nobody caught it because the symptom looked like noise.
Strict Mode is a useful signal amplifier. It is not a safety net. The cleanup contract has to be enforced by code review and by engineers understanding the underlying mechanics, not by a development-only double-invocation behaviour that is easy to dismiss.
Does the React Compiler Help? The Honest Answer.
Since React 19 and the gradual rollout of the React Compiler (previously called React Forget), I have been asked repeatedly whether the compiler changes the leak story. The short answer is: it helps with performance, but it does not help with memory leaks in the way most people hope.
The React Compiler's primary job is automatic memoisation - it analyses component code and inserts useMemo and useCallback equivalents where they would prevent unnecessary re-renders, without you having to write them manually. This is genuinely valuable, and it does indirectly touch useEffect by potentially reducing how often effects re-run by stabilising dependencies.
But it cannot write cleanup functions for you. The compiler does not know that your addEventListener call needs a corresponding removeEventListener. It does not know that your chart library needs its .destroy() method called. It does not know that your WebSocket connection should be closed. That semantic knowledge lives in the domain of what your code means, not in what its syntax looks like. Static analysis can flag some patterns, but the compiler cannot infer intent.
In codebases running the React Compiler, I have found the same five leak archetypes as in codebases without it. The compiler's effect on useEffect cleanup is minimal because cleanup is fundamentally a semantic problem, not a syntactic one. The compiler optimises render behaviour. Cleanup correctness is still the engineer's responsibility.
Where the compiler genuinely helps is in reducing the number of unnecessary effect re-runs by stabilising dependency arrays. Fewer effect re-runs means fewer cleanup-and-reinitialise cycles, which means slightly less opportunity for leaks from effects that do partial cleanup. But this is a reduction in frequency, not a fix for the underlying missing cleanup.
There are tools that genuinely help more. The eslint-plugin-react-hooks rule exhaustive-deps will not catch missing cleanup functions, but it will catch stale closures that often accompany leaks. The eslint-plugin-react has some patterns around cleanup. But the most effective tool I have seen in actual codebases is a code review culture where useEffect cleanup is treated as a required part of any effect that touches external resources - not an optional enhancement.
The Cleanup Contract: Rules That Actually Work
After 40 audits and more useEffect cleanup conversations than I can count, I have distilled the mental model that prevents leaks into a set of rules that are easy to apply during code review and during the original writing of the code.
Rule 1: Every External Resource Must Have a Corresponding Release
If a useEffect creates anything that exists outside React's reconciliation cycle - an event listener, a timer, a subscription, a WebSocket, a third-party library instance, a Web Worker, an IntersectionObserver, a MutationObserver - the cleanup function must release it. No exceptions. The question to ask when writing any useEffect: "Does this create anything that lives outside React?" If yes, the cleanup function is not optional.
Rule 2: The Cleanup Function Must Clean Up the Same Handler It Registered
This one is subtle and responsible for a class of bugs where engineers are certain their cleanup is correct but events are still firing after unmount. The mistake looks like this:
// This leaks. handler in addEventListener and removeEventListener // are different function references. The listener is never removed. useEffect(() => { window.addEventListener('scroll', () => handleScroll(value)); return () => window.removeEventListener('scroll', () => handleScroll(value)); }, [value]); // Correct. The same reference is added and removed. useEffect(() => { const handler = () => handleScroll(value); window.addEventListener('scroll', handler); return () => window.removeEventListener('scroll', handler); }, [value]);
Rule 3: Treat useEffect as Symmetrical Code
A useful way to think about useEffect is as a pair of operations: setup and teardown. Every line of setup code should have a corresponding line of teardown code. If the setup block has three operations (add listener, start interval, initialise library), the cleanup block should have three corresponding operations (remove listener, clear interval, destroy library). A cleanup function shorter than the setup block is a signal to look harder.
Rule 4: Check the Library Docs for a Destroy or Cleanup Method
When integrating a third-party library inside a useEffect, the first thing to look up in its documentation is not "how to initialise" but "how to clean up." Most libraries that manage DOM state have a destroy, dispose, remove, or cleanup method. If the documentation does not mention one explicitly, check the source or issues - it probably exists and is just underdocumented.
The Patterns That Appear in Every Codebase - Fixed
ResizeObserver Leak
ResizeObserver is becoming ubiquitous as more components need to respond to their own size changes. I found unclean ResizeObserver usage in 28 of 40 codebases. The observer is created, a target is observed, the component unmounts, and the observer continues to hold a reference to the target element and its callback.
useEffect(() => { const element = ref.current; if (!element) return; const observer = new ResizeObserver(([entry]) => { setWidth(entry.contentRect.width); }); observer.observe(element); return () => { observer.unobserve(element); observer.disconnect(); }; }, []);
WebSocket Connection Leak
Real-time features built with WebSockets are a particularly bad category because each leaked connection holds a live network socket, not just a JavaScript object. Leaked WebSocket connections consume server-side resources as well as client-side memory, and in applications with session-based real-time features, this can silently multiply active connections far beyond what the server expects.
useEffect(() => { const socket = new WebSocket(wsUrl); socket.onmessage = (event) => { const data = JSON.parse(event.data); dispatch({ type: 'MESSAGE_RECEIVED', payload: data }); }; socket.onerror = (err) => setError(err); return () => { // CLOSE_NORMAL = 1000. Always use a code. socket.close(1000, 'Component unmounted'); }; }, [wsUrl]);
Chart.js Instance Leak
Chart.js is the most common charting library I encounter in audits, and its destroy pattern is the most commonly missing. A Chart.js instance holds a reference to the canvas element, its rendering context, and all the data passed to it. Without calling destroy(), none of this is released.
useEffect(() => { const ctx = canvasRef.current.getContext('2d'); const chart = new Chart(ctx, { type: 'line', data: chartData, options: chartOptions, }); // Store a reference so cleanup can access it. chartInstanceRef.current = chart; return () => { chart.destroy(); chartInstanceRef.current = null; }; }, [chartData, chartOptions]);
The Checklist I Run on Every React Codebase
This is the actual checklist I use when doing a memory audit. It is designed to be run mechanically - no deep analysis required for the first pass. Just grep for these patterns and investigate every result.
- Search for
addEventListener. Every occurrence inside a useEffect must have a correspondingremoveEventListenerin a cleanup function with the exact same reference. - Search for
setIntervalandsetTimeout. Every one must store the return value and callclearIntervalorclearTimeoutin cleanup. - Search for
subscribe(,.on(,.addListener(. Every subscription call needs a matching unsubscription in cleanup. - Search for
new ResizeObserver,new IntersectionObserver,new MutationObserver. Every observer needsdisconnect()in cleanup. - Search for
new WebSocket. Every socket needssocket.close()in cleanup. - Search for third-party library instantiation inside useEffect (new Chart, new Map, new Editor, etc.). Look up each library's cleanup/destroy API and verify it is called in cleanup.
- Search for
fetch(inside useEffect. Verify each one uses AbortController and that the abort signal is passed to the fetch call. - Run the mount-unmount-mount heap snapshot test on every component that appears in the high-traffic paths of the application.
Run a global search for each of the patterns in the checklist above. For any useEffect that contains these patterns, check whether a cleanup function exists and whether it reverses every operation in the setup. This mechanical pass will find the majority of leaks in most codebases without requiring any specialised tooling or deep analysis. In my experience, this takes 30 to 60 minutes on a large codebase and surfaces enough issues to justify the time investment immediately.
The remaining 20% - leaks from more complex patterns like closures holding large datasets, or library-specific cleanup requirements not covered by the above - require the heap snapshot approach. But start with the checklist. The signal-to-noise ratio is excellent.
What This Actually Says About How We Write React
The fact that every codebase I reviewed had at least one memory leak is not an indictment of the engineers who wrote them. These were not careless people writing bad code. Many of these codebases were well-architected, well-tested, and maintained by experienced teams. The leaks existed because the mental model of useEffect that most engineers carry - "it runs some code; React cleans it up" - is incomplete in a way that the framework itself does not loudly correct.
React's model is actually more demanding than that. The framework provides the mechanism for cleanup, but the semantic responsibility is entirely yours. React will call your cleanup function. What that function does is up to you. And if it does nothing, nothing gets cleaned up.
The deeper problem is that the consequences are invisible in development and slow-moving in production. A memory leak does not throw an error. It does not break a test. It does not cause a build failure. It just quietly accumulates until a user on a long session starts noticing that the tab is consuming 800MB of RAM and their laptop fan has been running for an hour.
"The cleanup function is not a courtesy to React. It is a contract with the garbage collector. When you do not honour it, the objects you created never get reclaimed - and the user's browser pays the bill."
The fix, thankfully, is not complex. It does not require a new architecture or a different state management approach or a migration to a different framework. It requires one thing: treating cleanup as a required component of any effect that touches external resources, every time, without exception. That is a discipline question more than a knowledge question - and it is one that can be enforced through code review culture, through pair programming on effects, and through teams collectively agreeing that a useEffect with external resources and no cleanup function is an incomplete implementation, not a working one.
Run the checklist on your codebase this week. I would be surprised if you found nothing.
Chrome DevTools Memory Panel React DevTools eslint-plugin-react-hooks AbortController API React 19 React Compiler
The heap snapshot technique described in section 04 works in any Chromium-based browser - Chrome, Edge, Arc, Brave. The allocation timeline recording is available in the same Memory panel. No additional tooling is required to run the 30-minute checklist audit.
Related essays
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.
- Hydration · React
Hydration Is the Most Expensive Lie in Frontend
Hydration makes your browser rebuild the entire page a second time just to make it interactive. Here is what it actually costs, and how islands, resumability, and Server Components are quietly replacing it.