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.

Retained Route Tree After Navigation Two navigation states side by side. Navigation 1 renders Route A, reachable from the React root. Navigation 2 renders Route B as the new active tree, while Route A's fiber and DOM nodes remain in memory, held by three retainers: a query cache entry, a context provider, and a global event listener, instead of being garbage collected. React root Current tree pointer Route B active, reachable Retained (unreachable via root) Route A fiber unmounted, not freed Route A DOM detached nodes Query cache entry keyed by old URL Context provider state never cleared Global listener closure over Route A Each arrow is a GC root path — Route A stays reachable until cut. Nav 1: mount A Nav 2: mount B, A retained Nav 3+: retained count grows

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

  1. 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.

  2. 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.

  3. 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.

  4. Inspect the Retainers pane for one surviving instance. Click a retained FiberNode or DOM element, expand the Retainers tree at the bottom of the panel, and walk upward until you reach a GC root — typically a Map, a Set, a closure variable, or a global object property. Verification: you can name the exact object (cache, provider, listener) holding the reference before moving on.

  5. 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.