Memory and Long Sessions
Date: 2026-08-17
Leaks that only appear after twenty minutes. Every performance test measures a fresh page load, so a site that degrades over a session passes every check and gets slower for the people using it most — which on a retail site is the ones about to buy.
A memory leak is memory the page allocated and no longer needs but can’t be garbage-collected because something still references it — so usage grows the longer the page stays open.
Why nobody catches it
WHAT'S MEASURED WHAT USERS DO
fresh page load arrive, browse 14 products,
one navigation filter, sort, compare, open a
5-second Lighthouse run size guide, return, add to basket,
go to checkout
memory at t=0 30 minutes, 40 client-side
navigations, one tab never closed
A single-page application never unloads. On a traditional multi-page site every navigation is a fresh document and memory resets; with client-side routing the same JavaScript context runs for the whole visit, so anything not cleaned up accumulates — Rendering Strategies.
The symptom sequence is recognisable: fine for ten minutes, sluggish scrolling by twenty, interactions taking noticeably longer by thirty, and on a lower-end phone the tab is eventually discarded and reloaded — losing the basket.
The four leaks that cause most of it
Listeners never removed. The commonest by a distance.
// leaks: the listener holds the component, which holds its subtree
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// correct: clean up on unmount
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);A listener on window or document outlives the component that added it, and it holds a reference to that component’s whole closure. Forty navigations, forty listeners, forty retained component trees.
Timers that keep running. setInterval without clearInterval runs forever, holds its closure, and on a route the user left ten minutes ago it’s still polling.
Observers not disconnected. IntersectionObserver, ResizeObserver and MutationObserver all hold their targets. Every lazy-loaded image component that creates one and never calls disconnect() retains its element.
Detached DOM nodes. A node removed from the document but still referenced by JavaScript — usually a cached lookup — keeps itself and all its children in memory.
// the element is gone from the page; this reference keeps the whole
// subtree alive indefinitely
this.cachedModal = document.querySelector('#product-modal');Detached nodes are the highest-volume leak because one retained node can hold a large subtree — The DOM.
The ones specific to commerce
- Growing caches with no eviction. A client-side product cache keyed by SKU, on a category page where someone scrolls through 400 products, holds 400 product objects plus their images’ decoded data
- Infinite scroll that never removes. Rendering 600 product cards means 600 live DOM subtrees, their listeners and their images. List virtualisation — rendering only what’s near the viewport and recycling the nodes — is the fix, and it’s the single most effective change on a long listing page
- Analytics and session replay buffers. Replay tools buffer DOM mutations; on a long session that buffer grows. Usually capped by the vendor, worth verifying — Session Replay
- Third-party scripts you can’t fix. Chat widgets and personalisation tags are common offenders and the leak isn’t yours to repair — Third-Party Scripts
Finding them
The method is comparative, not absolute — a single memory reading tells you nothing.
1 baseline open the page, force garbage collection, take a heap snapshot
2 exercise do the thing 10–20 times — navigate away and back,
open and close the modal, filter and reset
3 settle force garbage collection again
4 compare take a second snapshot, diff against the first
anything retained that shouldn't be → a leak
Steps 1 and 3 are what make it work. Without forcing collection you’re measuring garbage that hasn’t been cleared yet, and every page will look like it leaks.
DevTools’ Detached Elements panel is the fastest route to the most common leak, and the heap snapshot’s retainer chain tells you what’s holding a given object — which is the answer, not the object itself.
Measuring it in the field
Lab testing catches leaks you thought to exercise. Field measurement catches the ones you didn’t.
performance.memorygives a rough heap size in Chromium browsers. Coarse, and enough to spot a trend when reported alongside session duration — Real User Monitoring- Correlate INP against time-in-session. If interactions get slower the longer someone stays, that’s the signature — and it’s visible in RUM data you may already collect without anyone having looked at it that way
- Watch for tab discards and reloads, which on mobile mean the browser reclaimed the tab. A reload mid-journey is lost revenue and it appears in analytics as a bounce, not as a crash
Prevention
- Every subscription gets a cleanup, enforced in review. This is a checklist item, not a judgement call — Code Review
- Use framework cleanup hooks, and treat a missing return from
useEffectwith a subscription in it as a defect AbortControllerfor fetch and listeners, which cancels a whole group at once and is much harder to get wrong than paired add/remove calls- Virtualise long lists by default on listing pages
- Test a long session deliberately — a scripted run of 30 navigations with a heap check at the end, in CI — Performance Regression Testing
- Cap any client-side cache with an explicit eviction policy — Caching Strategies
Where it interacts
- Garbage Collection — the mechanism that reclaims memory, and why holding a reference prevents it
- Interaction to Next Paint — the metric a growing heap degrades, via longer collection pauses
- Memory Models — the underlying model of what’s retained and why
- Event Handling — where listener lifecycle is decided, and the source of the most common leak