Skip to content

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

  1. DevTools → Memory → Heap snapshot. Take one after page load.
  2. Perform your navigation cycle 5 times (PDP → PLP → PDP → …).
  3. Click the trash icon to force GC.
  4. Take a second snapshot.
  5. Select "Comparison" view, sorted by "Delta". Look for:
  6. Detached HTMLDivElement and similar — detached DOM
  7. Component names with a growing count — leaked React trees
  8. 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.

A 2000×2000 AVIF that's 180 KB on disk decodes to:
2000 × 2000 × 4 bytes = 16 MB in memory

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

  1. Manual snapshot comparison: navigate PDP ↔ PLP five times, force GC, compare snapshots. Sort by delta and look for detached nodes and growing component counts.
  2. Audit effects:
    rg -n "addEventListener" app components | rg -v "removeEventListener"
    rg -n -A6 "setInterval|new IntersectionObserver|new ResizeObserver" app components | rg -B6 -L "clearInterval|disconnect"
    
    Review every hit.
  3. Audit module‑level state: any Map, Set, or array declared outside a component that grows. Bound them.
  4. Check image decode size on your PLP: naturalWidth × naturalHeight × 4 summed across all images.
  5. Add the Playwright memory and long‑session INP tests to CI.
  6. Ship field memory sampling correlated with navigation count.

Checklist

  • Every addEventListener has a matching removal (or uses AbortController)
  • 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 sizes correct — 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

Next: 8.5 Experimentation without regressions