24 min read

FE-07 — Memory, Leaks & the Browser Heap

How V8 Manages Memory, Why SPAs Leak, and How to Find and Fix Every Leak Pattern

Part of The Production Frontend Engineer series. Prerequisites: FE-00 (browser internals, event loop), FE-02 (React component lifecycle), FE-03 (JavaScript parse and compile cost).


Opening

After this chapter, you will be able to open Chrome DevTools, take two heap snapshots twenty minutes apart on a running SPA, and tell your team exactly which object is leaking, which line of code created it, and why the garbage collector cannot free it. Most engineers treat memory leaks as mysterious — the app "just slows down" after a while, a tab "just crashes" after an hour. There is nothing mysterious about a memory leak. It is a specific object in the V8 heap that has a reference path keeping it alive when it should be dead. Every leak has a physical cause, a traceable reference chain, and a fix. This chapter gives you the tools to find all of them.


Part 1: The V8 Heap — What Memory Actually Is

When your JavaScript code runs, every object, array, function, closure, and string that it creates is allocated on the V8 heap — a region of memory that V8 reserves from the operating system. The heap is not a single flat block. V8 divides it into spaces with different characteristics, each optimised for different object lifetimes.

The two spaces that matter for understanding leaks:

Young generation (new space): small (typically 1-8MB). Objects are allocated here first. The garbage collector runs on the young generation very frequently — often dozens of times per minute — using a fast algorithm called Scavenge. Scavenge is based on a simple observation called the generational hypothesis: most objects die young. The vast majority of objects you create (temporary variables, intermediate values in a computation, objects created and discarded within a single event handler) are allocated in the young generation and collected in the next Scavenge cycle. If an object survives two Scavenge cycles, V8 promotes it to the old generation.

Old generation: large (can grow to gigabytes). Objects that have survived young generation collection live here. The garbage collector runs on the old generation much less frequently, using a more expensive algorithm (Mark-Sweep-Compact). Old generation collection can cause visible pauses — the "GC pauses" that cause occasional jank.

The practical consequence: most JavaScript objects cost almost nothing in memory terms — they are allocated and collected before they ever leave the young generation. Memory leaks are specifically about objects in the old generation that accumulate indefinitely because something keeps them alive. Every leak investigation is a search for the chain of references preventing old generation collection.


Part 2: The Garbage Collector — What "Reachable" Means Physically

V8 uses a reachability-based garbage collector. An object is alive if it is reachable from a set of root objects. It is dead — eligible for collection — if no root can reach it through any chain of references.

The root objects are:

  • The global object (window in the browser)
  • The current call stack (all local variables in all active function calls)
  • Any variables captured by closures that are themselves alive
  • DOM nodes that are part of the live DOM tree
  • Objects referenced by Service Workers or other browser APIs

The garbage collector's algorithm (simplified):

  1. Start from every root object
  2. Mark every object reachable from any root
  3. Sweep: anything not marked is unreachable and its memory is freed
  4. Compact: move surviving objects together to reduce fragmentation

"Freeing memory" means returning that region of the heap to V8's pool for future allocations. The operating system does not see individual object frees — V8 manages its own heap internally and returns large blocks to the OS periodically.

A memory leak is precisely this: an object that should be dead (your code is done with it) but remains reachable from a root (something still holds a reference to it). The garbage collector cannot free it because it cannot know your intention — it can only see reachability. If a reference exists, the object stays alive.

This is the physical definition of a leak: an unintended reference that keeps an object reachable when your code is done with it.


Part 3: The Five SPA Leak Patterns

In multi-page apps, navigation destroys the entire JavaScript context. Every object is freed. Memory leaks are self-correcting on every page load. In SPAs, the JavaScript context lives indefinitely — React mounts and unmounts components, but the same V8 heap runs for the entire session. Leaks accumulate monotonically. The app slows down. Eventually, the browser tab crashes.

These are the five patterns responsible for virtually every SPA memory leak.

Pattern 1: Event Listeners Not Removed on Unmount

javascript
// React component — leaks event listener on unmount
function ScrollTracker() {
  useEffect(() => {
    function handleScroll() {
      setScrollY(window.scrollY)
    }

    window.addEventListener('scroll', handleScroll)

    // ❌ Missing cleanup — the listener is never removed
    // When ScrollTracker unmounts, the component function is gone
    // but window still holds a reference to handleScroll
    // handleScroll closes over setScrollY
    // setScrollY is the state setter from the unmounted component
    // The component's state and fibre node remain in memory
  }, [])

  return <div>Scroll: {scrollY}</div>
}

// Correct version
function ScrollTracker() {
  useEffect(() => {
    function handleScroll() {
      setScrollY(window.scrollY)
    }

    window.addEventListener('scroll', handleScroll)

    return () => {
      window.removeEventListener('scroll', handleScroll) // cleanup
    }
  }, [])

  return <div>Scroll: {scrollY}</div>
}

The reference chain that causes the leak:

code
window (root)
  → scroll event listener registry
    → handleScroll function
      → closure scope (captures setScrollY)
        → React state setter
          → React fibre node (the unmounted component's internal state)
            → all objects referenced by the component's state

The window is a root. It holds the scroll listener. The listener closes over the state setter. The state setter is tied to the React fibre. The fibre holds the component's state. Everything in this chain lives until removeEventListener is called.

This pattern scales with component mount/unmount frequency. A component that mounts and unmounts on every route change with this leak accumulates one listener per navigation. After 50 navigations, 50 listeners fire on every scroll event.

Pattern 2: Timers Not Cleared on Unmount

javascript
// Polling interval leaks the component on unmount
function LivePrice({ symbol }) {
  const [price, setPrice] = useState(null)

  useEffect(() => {
    const interval = setInterval(async () => {
      const p = await fetchPrice(symbol)
      setPrice(p) // calling setPrice on an unmounted component
    }, 5000)

    // ❌ interval not cleared
    // After unmount: the interval fires every 5 seconds
    // Each fire calls setPrice, which React ignores (unmounted)
    // but the network request still happens
    // and the closure keeps symbol, setPrice, and the component fibre alive
  }, [symbol])

  return <div>{price}</div>
}

// Correct version
useEffect(() => {
  const interval = setInterval(async () => {
    const p = await fetchPrice(symbol)
    setPrice(p)
  }, 5000)

  return () => clearInterval(interval) // cleanup
}, [symbol])

The reference chain:

code
window (root)
  → timer registry (internal browser list of pending timers)
    → interval callback (the async function)
      → closure scope (symbol, setPrice)
        → React fibre node

Timers are held by the browser's timer registry, which is a root. As long as the timer exists, the closure is alive. The closure keeps the component alive.

Pattern 3: Closures Over Large Objects

javascript
function processData() {
  const hugeArray = new Array(1000000).fill({ value: Math.random() })
  // hugeArray is ~8MB

  const summary = hugeArray.reduce((acc, item) => acc + item.value, 0)

  // Bug: the returned function closes over hugeArray
  // even though it only uses summary
  return function getSummary() {
    console.log(summary, hugeArray.length) // references hugeArray
  }
}

const getSummary = processData()
// hugeArray (8MB) is now alive for as long as getSummary exists
// because getSummary closes over it

// Fix: don't close over what you don't need
function processData() {
  const hugeArray = new Array(1000000).fill({ value: Math.random() })
  const summary = hugeArray.reduce((acc, item) => acc + item.value, 0)
  const length = hugeArray.length // capture only what's needed

  return function getSummary() {
    console.log(summary, length) // no reference to hugeArray itself
  }
  // hugeArray is now unreachable after processData returns
  // GC can collect it
}

This pattern appears in React in subtle ways: a useCallback that closes over a large state object, or a useMemo that returns a function closing over a previous render's entire props object.

Pattern 4: Detached DOM Nodes

javascript
// A DOM node removed from the tree but still referenced in JavaScript
function setupTable() {
  const table = document.getElementById('data-table')
  const rows = table.querySelectorAll('tr') // NodeList of row elements

  // Store rows in a global map for quick access
  window.rowCache = {}
  rows.forEach(row => {
    window.rowCache[row.dataset.id] = row // direct DOM node references
  })

  // Later: table is removed from the DOM
  table.remove() // table is detached from the live tree

  // But window.rowCache still holds references to all row elements
  // The rows (and their entire subtrees) cannot be garbage collected
  // They are "detached DOM nodes" — not in the live tree, not free
}

Detached DOM nodes are a classic React leak when you store DOM refs in module-level variables or in objects that outlive the component. The DOM node is removed from the tree (component unmounts), but a JavaScript variable still references it. V8 cannot free it because the variable is still reachable.

The reference chain:

code
window (root)
  → rowCache
    → DOM node (detached from live tree)
      → all child DOM nodes
        → all event listeners on those nodes
          → all closures in those listeners

One detached table row with 50 cells and listeners on each cell can retain megabytes through this chain.

Pattern 5: Forgotten Subscriptions (RxJS, EventEmitter, WebSocket)

javascript
// Component subscribes to a data stream on mount
// Does not unsubscribe on unmount
function LiveChart({ channelId }) {
  const [data, setData] = useState([])

  useEffect(() => {
    const subscription = dataStream
      .filter(event => event.channelId === channelId)
      .subscribe(event => {
        setData(prev => [...prev, event.value])
      })

    // ❌ subscription.unsubscribe() never called
    // dataStream holds a reference to the subscriber callback
    // callback closes over setData and channelId
    // component stays alive as long as dataStream is alive
  }, [channelId])

  return <canvas ref={chartRef} />
}

// Correct:
useEffect(() => {
  const subscription = dataStream
    .filter(event => event.channelId === channelId)
    .subscribe(event => setData(prev => [...prev, event.value]))

  return () => subscription.unsubscribe()
}, [channelId])

Any pub/sub system — RxJS Observable subscriptions, Node.js EventEmitter listeners, WebSocket message handlers, custom event buses — holds a reference to every subscriber. If the subscriber is not removed when the component unmounts, the pub/sub system (which is typically a module-level singleton, reachable from the module root) keeps the subscriber function alive, which keeps the component alive.


Part 4: The Heap Snapshot Workflow — Finding Any Leak in Four Steps

Chrome DevTools provides the Memory panel with three tools: Heap Snapshot, Allocation Timeline, and Allocation Sampling. For leak investigation, Heap Snapshot is the primary tool.

The Core Technique: Snapshot Delta

The principle: take a heap snapshot in a known-good state. Perform the action that may leak (navigate to a page, open a modal, mount a component). Trigger the action that should clean up (navigate away, close the modal, unmount the component). Take a second snapshot. Compare the two. Objects that should have been collected but are still alive appear as the delta between snapshots.

Step 1: Set up a controlled scenario

Navigate to your app in a new tab. Perform the suspect action several times to warm up the V8 heap (first-time allocations of React internals and framework code will be present in all snapshots — you want them stabilised before your baseline snapshot).

Step 2: Take the baseline snapshot

DevTools → Memory tab → select "Heap snapshot" → click "Take snapshot." The snapshot process pauses JavaScript execution briefly (minor GC pause) and captures every object on the heap with its size and reference relationships. The snapshot file is typically 10-50MB for a medium React app.

Step 3: Perform and undo the suspected operation

If you suspect a component leaks: mount it, interact with it, unmount it. If you suspect a navigation leaks: navigate to the route, navigate away. Do this 5-10 times — multiple repetitions make leaks obvious (the leaked objects accumulate, making them larger and easier to spot against the background of stable objects).

Step 4: Take the second snapshot and compare

Take a second heap snapshot. In the dropdown at the top of the snapshot view, change "Summary" to "Comparison." The view now shows the delta: objects created between snapshot 1 and snapshot 2, grouped by constructor type.

Sort by "# Delta" (net new objects) descending. You are looking for:

  • Your own component names (e.g., UserProfile, DashboardChart) — React keeps named component functions in the heap, and their names appear in the constructor column
  • EventListener objects — indicate unremoved listeners
  • Detached HTMLDivElement, Detached HTMLSpanElement etc. — detached DOM nodes
  • Large Array or Object instances with unexpected counts

Step 5: Trace the retainer chain

Click on a suspicious constructor type. The lower panel shows individual instances. Click an instance. The "Retainers" panel shows the reference chain keeping this object alive — from the object back to the root.

Read the retainer chain from bottom to top — the bottom is the root (typically window), the top is your leaked object. Each line is one reference hop. The chain tells you exactly which variable, property, or closure holds the reference that prevents collection.

code
Example retainer chain for a leaked React component:

  ReactFibreNode  ← the leaked component's internal state
    ← closure (handleScroll)
      ← [[Scopes]] closure scope
        ← handleScroll function
          ← scroll event listener
            ← window._listeners (internal listener registry)
              ← Window (root) ← GC root

This chain tells you: the window's scroll listener holds handleScroll, which closes over a ReactFibreNode. Go to the code that adds the scroll listener — that is where the removeEventListener is missing.


Part 5: The Allocation Timeline — Finding Where Leaks Are Created

Heap snapshots tell you what is leaking. The Allocation Timeline tells you when and where objects are being allocated — which is useful when you cannot identify the leak from a snapshot alone.

DevTools → Memory tab → select "Allocation instrumentation on timeline" → record → perform the suspect action → stop.

The timeline shows blue bars representing allocations over time. Objects that were allocated and then collected appear grey. Objects that were allocated and are still alive appear blue. Clicking a blue bar filters the object list to show only objects allocated during that time window.

This is particularly useful for finding gradual leaks — objects that are not immediately obvious in a snapshot comparison but accumulate slowly over minutes of use. If the blue bars keep growing and do not grey out (are not collected), you have a continuous leak.


Part 6: WeakMap, WeakRef, and WeakSet — V8's Tools for Intentional Non-Retention

JavaScript provides three "weak" data structures specifically for cases where you want to associate data with objects without preventing their garbage collection.

The Problem They Solve

javascript
// Storing per-element metadata — strong reference causes leak
const elementData = new Map()

function annotate(element, data) {
  elementData.set(element, data) // Map holds strong reference to element
}

// Later: element is removed from DOM
element.remove()
// But elementData still holds the element → not collected
// If you annotate 1000 elements over the app's lifetime,
// all 1000 stay in memory even after removal

A Map holds strong references to its keys. The element cannot be collected as long as it is a key in the Map. The Map must be manually cleaned up — which requires knowing when the element is removed, which is exactly what you may not know.

WeakMap — Keys Are Not Retained

javascript
const elementData = new WeakMap()

function annotate(element, data) {
  elementData.set(element, data)
}

// Later: element is removed from DOM and all other references are gone
element.remove()
// WeakMap holds a WEAK reference to element as key
// When element has no strong references, GC can collect it
// WeakMap entry is automatically removed
// No memory leak

WeakMap holds weak references to its keys. If the key object has no other strong references, it can be garbage collected — the WeakMap entry disappears automatically. You do not need to manually clean up.

Constraints of WeakMap: keys must be objects (not primitives), WeakMaps are not iterable (you cannot enumerate all entries — only look up by key), and there is no size property. These constraints exist because the GC manages entry lifetimes asynchronously — you cannot guarantee what entries exist at any given moment.

javascript
// Common React pattern: caching computed values per DOM element
const measureCache = new WeakMap()

function measureElement(element) {
  if (measureCache.has(element)) {
    return measureCache.get(element)
  }
  const rect = element.getBoundingClientRect()
  measureCache.set(element, rect) // cached per element, freed when element is GC'd
  return rect
}

WeakRef — Optional References

javascript
// Sometimes you want to reference an object but not prevent its collection
const cache = new Map()

function getCached(key, createFn) {
  const ref = cache.get(key)
  const cached = ref?.deref() // returns the object, or undefined if GC'd

  if (cached) return cached

  const fresh = createFn()
  cache.set(key, new WeakRef(fresh)) // weak reference — GC can collect fresh
  return fresh
}

WeakRef wraps an object in a weak reference. deref() returns the object if it is still alive, or undefined if it has been collected. This is useful for caches where you want to reuse objects if they are still around but do not want to prevent collection if memory is needed elsewhere.

WeakRef with FinalizationRegistry allows you to run cleanup code when an object is collected:

javascript
const registry = new FinalizationRegistry((key) => {
  cache.delete(key) // clean up the cache entry when the value is GC'd
})

function setWithCleanup(key, value) {
  cache.set(key, value)
  registry.register(value, key) // run cleanup(key) when value is collected
}

The FinalizationRegistry callback runs after the object is collected — it is not synchronous or guaranteed to run at a specific time. Do not rely on it for program correctness — only for optional cleanup.


Part 7: React-Specific Memory Patterns

The Stale Closure in useEffect

javascript
function SearchResults({ query }) {
  const [results, setResults] = useState([])

  useEffect(() => {
    let cancelled = false

    async function search() {
      const data = await fetch(`/api/search?q=${query}`).then(r => r.json())

      if (!cancelled) {
        setResults(data) // only update if effect is still active
      }
    }

    search()

    return () => {
      cancelled = true // mark as cancelled on cleanup
    }
  }, [query])

  return results.map(r => <Result key={r.id} result={r} />)
}

This pattern prevents state updates on unmounted components. Without the cancelled flag, if query changes before the first fetch completes, the effect runs again (new fetch starts), but the old fetch may still complete and call setResults — setting state on a component instance that is now showing different results, or on an unmounted component. The cancelled flag ensures only the most recent effect's fetch can update state.

This is not strictly a memory leak (the garbage collector handles the old effect's closure), but it is a correctness bug that can masquerade as unexpected re-renders and stale data — which engineers often spend hours debugging believing it to be a state management issue.

Context and Global State Retaining Component Trees

javascript
// This pattern retains mounted component state in a global store
// even after the user navigates away

const store = create((set) => ({
  dashboardData: null, // this could hold megabytes of chart data
  setDashboardData: (data) => set({ dashboardData: data }),
}))

function Dashboard() {
  const setDashboardData = useStore(state => state.setDashboardData)

  useEffect(() => {
    fetchDashboardData().then(setDashboardData)
  }, [])

  // Dashboard unmounts when user navigates away
  // But store.dashboardData still holds the full dataset
  // If the dataset is 50MB of chart data, it lives in memory indefinitely
}

Global stores (Zustand, Redux, module-level variables) are roots — they are reachable from the module system, which is reachable from the global scope. Anything stored in a global store is alive for the entire session. If you store large datasets in global state for "performance" (avoid re-fetching), you are trading network cost for memory cost. For very large datasets (>1MB), consider clearing the store when navigating away.


Part 8: V8 GC Pauses and Why They Cause Jank

The V8 garbage collector does not run for free. When it runs a major collection (old generation), it must pause JavaScript execution to do so. During this pause, no JavaScript runs — no event handlers, no React renders, nothing. The main thread is stopped. If the GC pause is long enough (>16ms), a frame is dropped. The user sees jank.

Modern V8 uses incremental and concurrent marking to reduce pause times. The marker runs concurrently (in a separate thread) during normal execution, pausing JavaScript only for short "atomic" phases. But with very large heaps (hundreds of MB), even incremental marking produces noticeable pauses.

What causes large heaps: accumulating objects in the old generation. The young generation is small and collected frequently. The old generation grows whenever objects survive long enough to be promoted. If you allocate many objects that survive a few frames (React fibre nodes, large arrays in closures, cached DOM measurements), the old generation grows and GC pauses lengthen.

Diagnosing GC pauses:

Chrome DevTools → Performance tab → record → perform interaction → stop. In the timeline, look for yellow bars labelled "Minor GC" and "Major GC." Major GC bars that exceed 16ms indicate the garbage collector is causing dropped frames.

The Memory tab shows the heap size over time while you interact. A healthy app shows the heap growing and then dropping (GC ran and freed memory). An app with leaks shows the heap growing monotonically — each GC cycle frees less than was allocated since the last cycle.


Part 9: Practical Audit — Confirming a Fix

After identifying a leak and implementing a fix, confirm the fix is complete:

Method 1: The 3-snapshot test

  1. Baseline snapshot (after warm-up)
  2. Perform the leaked action 10 times
  3. Second snapshot — check delta
  4. Perform cleanup (unmount, navigate away)
  5. Force GC: DevTools → Memory → click the trash can icon (forces a major GC)
  6. Third snapshot

If the fix is complete, the third snapshot's object count for your suspected type should be equal to or lower than the baseline. If leaked objects are still present in the third snapshot even after forced GC, the reference chain is still intact — the fix is incomplete.

Method 2: The heap size over time

DevTools → Memory → Allocation Timeline → record for 2-3 minutes of normal app usage. If the heap size grows continuously with no drops, there is an active leak. After your fix, record again — you should see the heap grow and periodically drop (GC collecting successfully) rather than growing without bound.


Vocabulary Map

TermWhat it physically is
V8 heapThe region of memory V8 allocates from the OS to store all JavaScript objects, arrays, functions, and closures — divided into young and old generation spaces with different collection algorithms
Young generationA small (~1-8MB) heap space where new objects are initially allocated — collected frequently with a fast Scavenge algorithm, most objects die here
Old generationA large heap space where objects are promoted after surviving young generation collection — collected infrequently with the more expensive Mark-Sweep-Compact algorithm
GC rootAn object from which the garbage collector begins its reachability traversal — includes window, the current call stack, and active closures — anything reachable from a root is alive
ReachabilityThe property of being connected to a GC root through any chain of object references — reachable objects are alive, unreachable objects are eligible for garbage collection
Memory leakAn object that is reachable from a GC root (preventing collection) but is no longer needed by the program — caused by an unintended reference that should have been removed
Heap snapshotA complete capture of the V8 heap at a specific moment showing every object, its size, and its reference relationships — used in DevTools to identify leaked objects
Retainer chainThe sequence of object references from a GC root to a specific object — shows exactly why an object cannot be garbage collected
Detached DOM nodeA DOM element that has been removed from the live document tree but still has a JavaScript reference — not in the UI, not in the tree, but alive in the heap
Forced synchronous GCTriggering the garbage collector manually via DevTools (trash can icon) — forces a major collection of the old generation, useful for confirming objects have been freed after a fix
WeakMapA Map-like structure where keys are held with weak references — entries are automatically removed when the key object has no other references and is garbage collected
WeakRefA wrapper around an object that holds a weak reference — deref() returns the object if still alive or undefined if collected — allows optional access without preventing collection
ScavengeV8's young generation garbage collection algorithm — fast, runs frequently, based on copying live objects to a new space and freeing the old space entirely
Mark-Sweep-CompactV8's old generation garbage collection algorithm — marks reachable objects, sweeps unreachable ones, compacts survivors to reduce fragmentation
Allocation TimelineA DevTools Memory tool that records object allocations over time — shows which objects were allocated and whether they were later collected, useful for finding gradual leaks
Incremental markingV8's technique for spreading GC mark phase work across multiple small pauses rather than one large pause — reduces user-visible jank from garbage collection

Practice Problems

Problem 1 — Failure Analysis

A React SPA has a route /analytics that renders a dashboard with multiple charts. Each chart component subscribes to a WebSocket stream for real-time updates. Users report that after navigating to /analytics and then away from it several times, the app becomes noticeably slower and eventually crashes the browser tab. A heap snapshot taken after five navigate-in/navigate-out cycles shows 47 instances of the ChartComponent constructor in the delta, each with a retainer chain terminating at a WebSocket object. The WebSocket object is not a GC root — it is stored in a module-level Map keyed by channel ID. Trace the complete reference chain that keeps ChartComponent alive after unmounting, explain why navigating away five times produces exactly 47 instances (not 5 or 10), and explain precisely what the fix must do at both the component level and the module level.

Problem 2 — Design from Scratch

You are building a virtualised list component that renders 50,000 items but only keeps 20 DOM nodes in the tree at any time (the visible window plus a buffer). As the user scrolls, DOM nodes are recycled — their content is updated in place rather than creating new nodes. Each row has click and hover event listeners added to it when it is created. Design the complete memory management strategy for this component: how event listeners are managed to avoid the Pattern 1 leak, how you would use WeakMap to cache row measurements without leaking DOM nodes, and what you would need to clean up when the entire virtualised list unmounts. Calculate approximately how many DOM nodes and event listeners your strategy keeps alive at any time, and compare this to a naive implementation that creates a new DOM node per item.

Problem 3 — Synthesis

A developer reads that useCallback prevents functions from being recreated on every render and adds it to every function in their application — including all event handlers passed as props to components. Three weeks later, a heap analysis shows the old generation growing by approximately 2MB per hour during normal use. The leak is traced to useCallback's dependency arrays holding references to previous renders' state and props objects through closure. Connect what you know about closures over large objects (Pattern 3 in this chapter), React's useMemo/useCallback implementation (FE-02), and V8's old generation promotion (this chapter's Part 1) to explain precisely how useCallback caused the leak, why it causes old generation growth specifically (not young generation), and what the correct criterion is for using useCallback that avoids this outcome.

Problem 4 — Go Deeper

V8's garbage collector uses reachability from GC roots to determine what is alive. The GC roots include the current call stack. Consider a React application where a component's useEffect starts an async function that awaits a 10-second network request. During those 10 seconds, the component unmounts and the user navigates away. The async function's execution context is on the call stack of the microtask queue, not the main call stack — but it is still "in-flight." Reason through: (1) whether the async function's closure is a GC root during the await, (2) whether the component it captured in its closure can be collected during those 10 seconds, (3) what happens to the captured state setter when the await resolves and the function tries to call it, and (4) what this tells you about the relationship between async operations, closures, and memory in React applications. This is a case the chapter does not explicitly cover — reason from the first principles it established.

Problem 5 — Previous Chapter Connection (FE-05)

In FE-05 you learned that a Service Worker with a Cache-First strategy keeps resources alive in the Service Worker cache — a form of intentional long-term retention. Compare the Service Worker cache (FE-05) with the V8 heap (this chapter) as two forms of memory retention in the browser: (1) what triggers retention in each (reference reachability vs explicit cache.put), (2) what triggers release in each (GC collection vs cache.delete or cache name versioning), (3) which category of data is appropriate for each, and (4) under what conditions would data stored in the Service Worker cache cause problems analogous to a memory leak — where the cache grows indefinitely and the "fix" is structurally identical to fixing a V8 heap leak. What does this tell you about the general principle of intentional versus unintentional retention in browser storage systems?


What's Coming Next

You can now find, trace, and fix every class of memory leak in a production SPA. FE-08 takes the diagnostic skills from this chapter and every previous chapter and grounds them in measurement — not "I think this is slow" but "I have data showing the 75th-percentile user on mobile Android experiences 420ms INP, the main contributor is a long task during route transition, and after my fix it dropped to 180ms." Real User Monitoring, custom performance marks, the Performance Observer API, and how to set up the instrumentation that turns performance work from guesswork into engineering.