8.4 — Memory & long sessions¶
Module 8 · Lesson 4 · 🔴 Advanced · ~30 min
What you'll learn¶
- Why memory matters for commerce even though nobody profiles it
- The five leak patterns in React SPAs, with fixes
- Measuring heap growth in development and in the field
- The long‑session degradation that synthetic tests never catch
Why this matters¶
Your synthetic tests load one page and stop. Your users browse 14 pages per session, and on a long session an SPA that leaks gets slower on every navigation:
Aurora PDP → PLP → PDP → PLP navigation cycle (mid-tier Android, 3GB RAM)
Nav 1: heap 42 MB INP 118ms
Nav 5: heap 81 MB INP 145ms
Nav 10: heap 134 MB INP 198ms
Nav 20: heap 241 MB INP 340ms
Nav 30: heap 388 MB INP 610ms ← GC pressure; occasional tab crash
The user experiences this as "the site gets slower the more I shop", which is the opposite of what you want, and it hits your most engaged users hardest — the ones who buy.
On mobile, exceeding available memory means the tab is killed. The user loses their cart state and their place. That's a lost order, and it shows up in your funnel as an unexplained drop.
The five leak patterns¶
1. Event listeners not removed¶
// ❌ Leaks a listener per mount; the closure retains the component's scope
useEffect(() => {
window.addEventListener('resize', handleResize);
// no cleanup
}, []);
// ✅
useEffect(() => {
window.addEventListener('resize', handleResize, { passive: true });
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]); // handleResize must be stable, or you add/remove every render
The subtle version: an unstable handler with a dependency array that changes every render adds and removes a listener constantly. It doesn't leak, but it's churn — and if you forget the dep array entirely, it leaks.
AbortController is cleaner for multiple listeners:
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('resize', onResize, { signal, passive: true });
window.addEventListener('scroll', onScroll, { signal, passive: true });
document.addEventListener('visibilitychange', onVisibility, { signal });
return () => controller.abort(); // removes all of them
}, []);
2. Timers and observers¶
// ❌ Interval keeps running after unmount, holding the component's closure
useEffect(() => {
setInterval(() => refreshStock(productId), 30_000);
}, [productId]);
// ✅
useEffect(() => {
const id = setInterval(() => refreshStock(productId), 30_000);
return () => clearInterval(id);
}, [productId]);
Same for IntersectionObserver, ResizeObserver, MutationObserver, and PerformanceObserver —
all need .disconnect().
useEffect(() => {
const io = new IntersectionObserver(onIntersect);
elements.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [onIntersect]);
3. Unbounded caches and stores¶
// ❌ Grows forever; every product the user views is retained
const productCache = new Map<string, Product>();
// ✅ Bounded LRU
class LruCache<K, V> {
private map = new Map<K, V>();
constructor(private max = 50) {}
get(key: K): V | undefined {
const value = this.map.get(key);
if (value !== undefined) {
this.map.delete(key); // re-insert to mark as recently used
this.map.set(key, value);
}
return value;
}
set(key: K, value: V) {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.max) {
this.map.delete(this.map.keys().next().value as K); // evict oldest
}
}
}
export const productCache = new LruCache<string, Product>(50);
The commerce‑specific version of this: client‑side data libraries with no cache eviction. Check your SWR/React Query configuration:
// React Query: bound the cache
new QueryClient({
defaultOptions: {
queries: {
gcTime: 5 * 60_000, // drop unused queries after 5 minutes
staleTime: 30_000,
},
},
});
4. Detached DOM nodes¶
A DOM node removed from the document but still referenced by JavaScript can't be collected — and it retains its whole subtree.
// ❌ The ref outlives the node; a module-level registry retains it forever
const nodeRegistry: HTMLElement[] = [];
function ProductCard() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { nodeRegistry.push(ref.current!); }, []); // never removed 💀
}
// ✅ Clean up, or use a WeakRef/WeakMap so the node can be collected
useEffect(() => {
const node = ref.current!;
registry.add(node);
return () => registry.delete(node);
}, []);
Infinite scroll is the classic source: 480 product cards accumulated across 10 pages, each with images and listeners, all retained. → cap the list or virtualize (5.4).
5. Closures capturing large objects¶
// ❌ The callback closes over the entire 2MB product list, and the callback
// outlives the render because it's registered globally
function ProductGrid({ products }: { products: Product[] }) { // 2MB
useEffect(() => {
analytics.onFlush(() => {
// Captures `products` — the whole array is retained as long as this
// handler is registered
logImpression(products.length);
});
}, [products]);
}
// ✅ Capture only what you need
function ProductGrid({ products }: { products: Product[] }) {
const count = products.length; // a number, not the array
useEffect(() => {
const off = analytics.onFlush(() => logImpression(count));
return off; // and unregister
}, [count]);
}
Measuring memory¶
In development: the heap snapshot workflow¶
- DevTools → Memory → Heap snapshot. Take one after page load.
- Perform your navigation cycle 5 times (PDP → PLP → PDP → …).
- Click the trash icon to force GC.
- Take a second snapshot.
- Select "Comparison" view, sorted by "Delta". Look for:
Detached HTMLDivElementand similar — detached DOM- Component names with a growing count — leaked React trees
- Large arrays or maps growing monotonically
The key signal is linear growth per cycle. A stable heap with fluctuation is fine; a heap that's 8 MB larger after every cycle is a leak, and 20 cycles later it's 160 MB.
The allocation timeline¶
DevTools → Memory → "Allocation instrumentation on timeline". Record while navigating. Blue bars are allocations; bars that stay blue (never turn grey) were never collected. Click one to see the retaining path.
In the field¶
// lib/memory-monitor.ts
export async function reportMemory(context: string) {
// The precise, cross-origin-isolated API (requires COOP/COEP headers)
if ('measureUserAgentSpecificMemory' in performance) {
try {
const result = await (performance as any).measureUserAgentSpecificMemory();
reportMetric({
name: 'memory_bytes',
value: result.bytes,
attribution: { context, breakdown: result.breakdown?.slice(0, 5) },
});
return;
} catch { /* not available in this context */ }
}
// The legacy, imprecise fallback (Chromium only)
const legacy = (performance as any).memory;
if (legacy) {
reportMetric({
name: 'memory_bytes_legacy',
value: legacy.usedJSHeapSize,
attribution: { context, limit: legacy.jsHeapSizeLimit },
});
}
}
// Sample periodically and on navigation, correlated with navigation count
let navCount = 0;
export function onRouteChange() {
navCount++;
if (navCount % 5 === 0) void reportMemory(`nav-${navCount}`);
}
Then chart memory vs navigation count. A rising line is a leak, visible across your whole user base rather than in one engineer's DevTools session.
SELECT navigation_count_bucket,
APPROX_QUANTILES(value, 100)[OFFSET(75)] / 1048576 AS p75_mb
FROM memory_metrics
WHERE device_class = 'mobile'
GROUP BY 1 ORDER BY 1;
-- 1-5 p75 48 MB
-- 6-10 p75 92 MB
-- 11-20 p75 178 MB ← leaking
-- 21+ p75 310 MB
Images and memory¶
Images are usually the largest memory consumer on a commerce page, and it's decoded size that counts, not file size.
Thirty such images in an infinite‑scroll grid is 480 MB of decoded bitmap. This is the real reason
correct sizes matters so much (2.1) — it's not just bandwidth,
it's a mobile tab crash.
Mitigations:
- Correct sizes so you decode a 400px image, not a 2000px one (16 MB → 0.64 MB)
- loading="lazy" so off‑screen images aren't decoded
- decoding="async" so decode doesn't block the main thread
- Virtualization removes off‑screen images from the DOM entirely, allowing collection
- Cap infinite scroll
Long‑session testing¶
Add this to your regression suite. It's the only way to catch this class of problem.
// tests/memory-leak.spec.ts
import { test, expect } from '@playwright/test';
test('heap does not grow unboundedly across navigations', async ({ page }) => {
const client = await page.context().newCDPSession(page);
await client.send('HeapProfiler.enable');
const measure = async () => {
await client.send('HeapProfiler.collectGarbage');
const { result } = await client.send('Runtime.evaluate', {
expression: 'performance.memory.usedJSHeapSize',
returnByValue: true,
});
return result.value as number;
};
await page.goto('/c/womens-knitwear');
await page.waitForLoadState('networkidle');
const baseline = await measure();
// 20 navigation cycles, the way a real shopper browses
for (let i = 0; i < 20; i++) {
await page.click('[data-testid="product-tile"]:first-child');
await page.waitForLoadState('networkidle');
await page.goBack();
await page.waitForLoadState('networkidle');
}
const after = await measure();
const growthMb = (after - baseline) / 1024 / 1024;
console.log(`Heap growth over 20 cycles: ${growthMb.toFixed(1)} MB`);
// Some growth is normal (caches, prefetched payloads). Unbounded growth isn't.
expect(growthMb).toBeLessThan(50);
});
Also test INP after the cycles, which is the metric users actually feel:
test('INP does not degrade over a long session', async ({ page }) => {
// …20 navigation cycles as above…
const latency = await page.evaluate(async () => {
const el = document.querySelector<HTMLInputElement>('input.facet-checkbox')!;
const t0 = performance.now();
el.click();
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
return performance.now() - t0;
});
expect(latency).toBeLessThan(250);
});
Aurora's leaks¶
| Leak | Cause | Growth per navigation |
|---|---|---|
| Analytics listeners | onFlush handlers registered per PDP, never removed |
3.2 MB |
| Product cache | Unbounded Map of every viewed product |
1.8 MB |
| Detached carousel nodes | Third‑party gallery not calling its destroy method | 2.1 MB |
Live stock setInterval |
Not cleared on unmount | 0.4 MB + CPU |
| Prefetched RSC payloads | Router cache config retaining everything | 1.1 MB |
| Total | 8.6 MB/nav |
After fixes: 0.4 MB per navigation (normal cache growth). p75 INP after 20 navigations went from 610 ms to 131 ms — barely different from the fresh‑session number.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Never testing long sessions | The degradation is invisible to synthetic tests |
| Missing effect cleanup | A leak per mount |
| Unbounded client caches | Linear growth forever |
| Retaining DOM nodes in module‑level structures | Detached subtrees |
| Closures capturing large arrays | Megabytes retained by a small callback |
| Oversized images | Decoded size is 20–100× file size |
| Uncapped infinite scroll | Hundreds of retained cards and images |
| Not measuring memory in the field | You only see it when users report crashes |
Lab 8.4 — Find your leaks¶
- Manual snapshot comparison: navigate PDP ↔ PLP five times, force GC, compare snapshots. Sort by delta and look for detached nodes and growing component counts.
- Audit effects: Review every hit.
- Audit module‑level state: any
Map,Set, or array declared outside a component that grows. Bound them. - Check image decode size on your PLP:
naturalWidth × naturalHeight × 4summed across all images. - Add the Playwright memory and long‑session INP tests to CI.
- Ship field memory sampling correlated with navigation count.
Checklist¶
- Every
addEventListenerhas a matching removal (or usesAbortController) - Every timer and observer is cleared/disconnected on unmount
- All client caches are bounded (LRU or a
gcTime) - No module‑level structures retaining DOM nodes
- Callbacks capture primitives, not large objects
- Image
sizescorrect — decode size is the real memory cost - Infinite scroll capped or virtualized
- Long‑session memory and INP tests in CI
- Field memory sampling correlated with navigation count