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.
Step-by-step resolution
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.
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.
Related
- GPU Memory & Texture Management — the memory model
- will-change Lifecycle with IntersectionObserver — promoting and releasing correctly
- Integrating View Transitions with Client-Side Routers — where route teardown fits