Releasing Memory After Page Transitions

Part of GPU Memory & Texture Management in Performance Budgeting & GPU Architecture.

The problem

A single-page application animates between routes with view transitions. After ten or fifteen navigations, animations that were smooth at the start stutter. Memory usage climbs steadily. Navigating back to the first route does not restore the original smoothness; only a full reload does.

Each transition leaves something behind — a promoted layer, a snapshot name, an animation that never finished, an observer holding a reference to a removed element — and the accumulation, not any single leak, is what degrades the experience.

Root cause analysis: four kinds of leftover

Promoted layers. An element with will-change: transform holds a texture for as long as it is rendered. If a route’s components add will-change on mount and never remove it, every visited route contributes layers. The lifecycle discipline is in will-change lifecycle with IntersectionObserver.

View transition names. Names applied to elements for a morph — often set in script just before navigating — must be unique at capture time. Names left on old elements collide with the next transition’s names, which skips the transition, and elements kept alive only to preserve a name hold their content in memory.

Script animations and observers. An Animation object with fill: 'forwards' keeps its target under animation control; an IntersectionObserver, ResizeObserver or event listener that was never disconnected keeps a reference to a removed element, which keeps the element and everything it references out of reach of garbage collection. These are detached nodes: removed from the document but still referenced.

Media. Videos that are removed from the DOM but still referenced, or canvases held in a cache, keep their buffers.

The fix is the same in each case: routes should clean up what they created, in a teardown step that runs when the route is left.

What a route leaves behind without teardownEach visited route adds to the total until the page is reloaded.What a route leaves behind without teardownPromoted layers (will-change)Texture per element, per routeView transition namesCollide with the next transitionFilling animationsKeep targets under animation controlObservers and listenersHold detached DOM aliveMedia buffersVideo and canvas memory
Each visited route adds to the total until the page is reloaded.

Step-by-step resolution

A route teardown routineUndo, in reverse, everything the route set up.A route teardown routine1Collect per-route cleanup functions as the route mounts.One place to tear down2On leaving, cancel animations and remove will-change.Layers released3Clear view transition names once the transition's finished promise settles.No collisions next time4Disconnect observers and remove listeners.No detached-node references5Pause and unload media the route owned.Buffers freed6Verify with the Layers panel and a heap snapshot after several navigations.
Undo, in reverse, everything the route set up.

Production code pattern

// A tiny per-route cleanup registry.
const cleanups = new Set();
export const onCleanup = (fn) => cleanups.add(fn);

export function teardownRoute(root) {
  for (const fn of cleanups) { try { fn(); } catch {} }
  cleanups.clear();

  for (const el of root.querySelectorAll('*')) {
    if (el.style.willChange) el.style.willChange = '';          // release promotions
    if (el.style.viewTransitionName) el.style.viewTransitionName = '';
  }
  for (const a of root.getAnimations({ subtree: true })) a.cancel();   // drop fills and layers

  for (const media of root.querySelectorAll('video, audio')) {
    media.pause();
    media.removeAttribute('src');
    media.load();                                                // release decoder buffers
  }
}

// Route change with a view transition and guaranteed cleanup.
async function navigate(url) {
  const oldRoot = document.querySelector('#route');
  const run = () => renderRoute(url);

  if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
    teardownRoute(oldRoot);
    return run();
  }

  const transition = document.startViewTransition(() => { teardownRoute(oldRoot); run(); });
  try { await transition.finished; } catch {}
  document.querySelectorAll('[style*="view-transition-name"]').forEach((el) => {
    el.style.viewTransitionName = '';                            // names only live for one transition
  });
}
/* Prefer state-scoped promotion so teardown is rarely needed. */
.card[data-animating] { will-change: transform, opacity; }

Rendering Impact: releases textures and animation state. Cancelling animations inside the update callback is deliberate: it happens while rendering is paused, so no intermediate frame shows the cancelled state.

Scoping will-change to a state attribute, as in the CSS above, is better than any teardown routine: the hint disappears when the state does, so a forgotten cleanup cannot leak a layer. Reserve script-driven promotion for cases where CSS cannot express the condition.

Composited layers after 15 route changesSame application, with and without teardown.Composited layers after 15 route changesNo teardown137 layersNames and animations cleared61 layersFull teardown24 layers
Same application, with and without teardown.

Verifying with DevTools

Two checks catch nearly everything. In the Layers panel, navigate several times and watch the layer count and total memory: healthy applications return to a stable baseline after each route, while leaks show a staircase. In the Memory panel, take a heap snapshot, navigate away and back, take another, and filter by “Detached” — detached DOM nodes that persist across snapshots are held by something, and the retainer tree names it, usually an observer, a listener or a cached animation object.

Add both to the release checklist rather than investigating only when users complain, because the symptom — gradually worse animation — appears long after the cause and is easy to misattribute to the animation itself.

Verification checklist

Constraints and trade-offs

  • Cancelling animations during teardown can cut short exit effects; run teardown after exits complete where that matters.
  • Removing a video’s source releases memory but forces a fresh download if the user returns.
  • Aggressive teardown can remove state users expect to persist, such as scroll position or form input.
  • Heap snapshots are large and slow; use them at release checkpoints rather than continuously.
  • Framework routers may cache route components deliberately; coordinate teardown with that cache.

Frequently asked questions

Why do animations get slower after many route changes?

Leftover promoted layers, filling animations and detached nodes accumulate, consuming GPU and heap memory until eviction and garbage collection start interfering with rendering.

Do I need to clear view-transition-name after a transition?

Yes, if names are applied dynamically. Duplicate names at capture time skip the next transition, and retained elements hold memory.

What are detached nodes?

DOM nodes removed from the document but still referenced by script — often by observers, listeners or animation objects — so they cannot be garbage collected.

How do I check whether memory is actually being released?

Watch the Layers panel across several navigations for a stable baseline, and compare heap snapshots filtered for detached nodes.