Why React Memory Grows on Every Route Change
If your heap climbs by a few megabytes on every navigation and never comes back down, the cause is almost always a retained route tree, not a real leak in the framework — this is a specific case of the broader React component memory leaks problem covered in the Framework-Specific Memory Optimization section.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Heap +2–8 MB per navigation | Old fiber tree retained | Compare snapshots by route name |
| Detached nodes accumulate | DOM nodes outlive unmount | Filter Comparison view by “Detached” |
| Growth stops after reload | In-memory cache, not disk | Inspect router/query cache retainers |
| Same component count rises | Global listener holds ref | Check addEventListener cleanup |
| Memory flat, CPU rises | Re-render, not retention | Separate issue — see flame graph guide |
Root Cause
React’s reconciler only guarantees that a component is removed from the active fiber tree when it unmounts — it does not guarantee the JavaScript engine can collect that fiber, its hooks closures, or the DOM nodes it rendered. Reachability is what decides collection, and on a route change there are usually several structures still holding a live reference to the outgoing tree.
The most common culprits, in order of frequency, are: a data-fetching cache (React Query, SWR, Apollo, or a hand-rolled Map) keyed by URL that never evicts old entries; a router’s own back/forward cache, which intentionally keeps a rendered segment in memory to make backward navigation instant; a Context.Provider higher in the tree that stores route-scoped data in state and never clears it on route change; and global listeners — window.addEventListener, a WebSocket onmessage handler, or a ResizeObserver — registered inside the route component but torn down conditionally or not at all. Each of these turns what should be a transient fiber and its associated detached DOM nodes into a permanent resident of Old Space.
The diagram below shows the shape of the problem: two navigations produce two fiber trees, but only one is reachable from React’s current root. The second is kept alive purely by side references — the exact pattern a heap snapshot comparison is built to expose.
Crucially, the retention is per-cycle: each navigation away from a route creates a new candidate for garbage collection, and each navigation that fails to release it adds one more retained tree. That is why the growth looks linear and step-shaped in a memory timeline rather than a single one-off jump — and why comparing three or more navigation cycles, not just one, is what distinguishes a real leak from ordinary allocation noise.
Step-by-Step Fix
-
Reproduce the growth on a clean baseline. Open the app, navigate to the route under suspicion, then navigate to a neutral route (e.g. the home page). In the DevTools console run
window.gc && window.gc()(Chrome must be launched with--js-flags=--expose-gc, or use the trash-can “Collect garbage” icon in the Memory panel). Take Snapshot 1. Verification: Snapshot 1’s total size is stable across two consecutive forced collections. -
Repeat the navigation cycle three to five times. Go to the route, then away, each time. After the fifth cycle, force garbage collection again and take Snapshot 2. Verification: Snapshot 2 is measurably larger than Snapshot 1 — if it is not, growth is not reproducible here.
-
Open the Comparison view and filter by the component. Go to DevTools → Memory → Heap Snapshot → Comparison view, select Snapshot 1 as the baseline, and type the route component’s function name (e.g.
CheckoutPage) into the class filter — this mirrors the workflow in take and compare heap snapshots. Verification: the “# New” column shows a count matching (or close to) the number of navigation cycles performed. -
Inspect the Retainers pane for one surviving instance. Click a retained
FiberNodeor DOM element, expand the Retainers tree at the bottom of the panel, and walk upward until you reach a GC root — typically aMap, aSet, a closure variable, or a global object property. Verification: you can name the exact object (cache, provider, listener) holding the reference before moving on. -
Cut the reference at its source. Evict the stale cache key, clear the context value in a cleanup effect, or remove the event listener in the same effect that added it. Rebuild, rerun the five-cycle navigation test, and take a fresh comparison snapshot. Verification: retained count for the route component returns to 0–1 regardless of how many cycles were run, and total heap size after forced GC is within roughly 5% of the original baseline.
Command & Code Reference
Use this snippet to give an unbounded route-keyed cache a hard upper bound so entries for routes the user no longer visits are evicted automatically.
// Bounded LRU-style cache keyed by route path.
const MAX_ENTRIES = 20; // cap prevents unbounded growth
const routeCache = new Map();
function setRouteCache(path, data) {
if (routeCache.has(path)) {
routeCache.delete(path); // refresh recency order
} else if (routeCache.size >= MAX_ENTRIES) {
// evict the least-recently-used entry (first key)
const oldestKey = routeCache.keys().next().value;
routeCache.delete(oldestKey);
}
routeCache.set(path, data); // insert as most-recent
}
Use this pattern to guarantee a global listener registered by a route component is removed the instant that component unmounts, closing the most common retention path.
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth); // route-scoped state
}
window.addEventListener("resize", handleResize);
// cleanup runs on unmount AND before re-running
return () => {
window.removeEventListener("resize", handleResize);
};
}, []); // empty deps: bind/unbind once per mount
Use this snippet to scope a context provider’s cache so it clears itself the moment the route it belongs to is left, rather than accumulating entries for every route ever visited.
function RouteDataProvider({ routeKey, children }) {
const [cache, setCache] = useState(() => new Map());
useEffect(() => {
// clear on route change, not just on unmount
return () => setCache(new Map());
}, [routeKey]); // depends on the active route id
const value = useMemo(
() => ({ cache, setCache }),
[cache]
);
return (
<DataContext.Provider value={value}>
{children}
</DataContext.Provider>
);
}
Verification & Regression Prevention
Set a concrete budget rather than relying on “it looks stable”: after five navigation cycles between the two heaviest routes, retained instances of each route’s root component should be 0–1 in a post-GC heap snapshot, and total JS heap size (performance.memory.usedJSHeapSize in Chrome, or process.memoryUsage().heapUsed for an SSR shell) should return to within 5% of the pre-cycle baseline.
Wire this into CI as an automated regression gate using Puppeteer to drive navigation and read heap metrics headlessly:
// ci-memory-check.js — run in a Puppeteer CI job
const puppeteer = require("puppeteer");
async function checkRouteMemory(url, cycles = 5) {
const browser = await puppeteer.launch({
args: ["--js-flags=--expose-gc"],
});
const page = await browser.newPage();
await page.goto(url);
for (let i = 0; i < cycles; i++) {
await page.click('a[href="/checkout"]');
await page.click('a[href="/"]');
}
await page.evaluate(() => window.gc()); // force GC
const { usedJSHeapSize } = await page.metrics();
await browser.close();
return usedJSHeapSize; // compare vs. stored baseline
}
module.exports = { checkRouteMemory };
Fail the build if usedJSHeapSize exceeds the stored baseline by more than 10% after accounting for expected chunk-loading overhead, and re-run the check whenever a new global cache, context provider, or addEventListener call is added to a route component — those are the three retainer types this diagnostic targets.
Frequently Asked Questions
Does unmounting a route component always free its memory?
No. Unmounting removes the fiber from React’s active tree and detaches its DOM nodes from the document, but the memory is only reclaimed once nothing else holds a reference to it. Global caches, module-level singletons, subscriptions that were never unsubscribed, and closures captured by timers or event listeners can all keep the old route’s fiber and DOM tree alive indefinitely.
How many navigations should I test before concluding there is a leak?
Navigate between the same two routes at least three to five times. A single navigation can show a small, legitimate increase from lazy-loaded chunks or warm caches. A leak shows a roughly linear, repeating increase per cycle that never drops back down after a forced garbage collection, and the retained count of the route’s root component keeps matching the number of navigations performed.
Do React Router and Next.js cache page components by default?
Both frameworks cache route data and, in some configurations, rendered segments to make back-navigation faster. React Router’s data router keeps loader results in memory, and Next.js’s client-side router cache retains rendered segments for a configurable staleTime. These caches are intentional, but if they are unbounded or keyed incorrectly they will retain full component and DOM trees well past the point where the user has moved on.