cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

Memory in long-lived apps

Why single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.

Why memory matters in long-lived apps

Picture a support dashboard. An agent opens it at 9 in the morning, keeps the tab open all day, and opens about 400 tickets, each in a side panel with the customer's history, a rich text editor, and a live chat feed. At 9 the tab uses about 90 MB of memory. By 5 in the afternoon it uses 1.6 GB, typing in the editor lags, and the laptop fan is loud.

Nothing crashed, and no error was logged. The app simply kept a little of every ticket it ever opened: a listener here, a cached response there, an old editor instance nobody destroyed. Each was small; four hundred of them were not.

This is a memory leak: memory the app no longer needs but cannot give back, because something still points at it. Leaks are the slow failure of long-lived apps. They don't show up in a five-minute test or in a Lighthouse run, and they rarely show up on a developer's fast laptop. They show up on hour six, on a mid-range phone.

Why single-page apps feel it most

A classic multi-page website gets a free reset on every click. The browser throws away the old page, its JavaScript heap, and its DOM, and starts the next page from zero. A leak there lives for one page view, often under a minute.

A single-page app (SPA) never gets that reset. Route changes swap components but keep the same JavaScript heap, the same window, the same module-level variables. Whatever one screen forgot to release is still there when the user reaches the next screen, and the one after. Email, chat, design tools, trading dashboards, music players, and admin consoles often stay open for days.

Bar chart of tab memory over an 8 hour working day. A multi-page site stays at about 70 MB because every navigation starts fresh. A healthy single-page app settles at about 130 MB. A leaking single-page app grows from 90 MB at the start to 300 MB after 1 hour, 520 MB after 2, 900 MB after 4, 1,250 MB after 6, and 1,600 MB after 8 hours.080016000 h1 h2 h4 h6 h8 hmulti-page sitehealthy SPAleaking SPAMB of memory, by hours the tab has been open
A multi-page site resets on every click. A single-page app keeps one heap all day: healthy ones level off, leaking ones keep climbing until the tab gets slow or is killed.

What users actually feel

A leak hurts in stages. Users rarely see a message that says "out of memory". They see the symptoms below, usually in this order.

  1. Slower garbage collection. The garbage collector (GC, the part of the engine that frees memory) has more live objects to walk. Its pauses grow from a few milliseconds to tens of milliseconds, and they land in the middle of typing and scrolling. Interaction to Next Paint (INP) gets worse with no obvious long task to blame.
  2. Slower everything else. A bigger DOM makes style and layout slower. Leaked listeners still run: 50 copies of a resize handler all fire on every resize.
  3. Swapping and battery drain. On a laptop, the operating system starts moving memory to disk (swapping), which slows down every app. On a phone, leaked timers and polls keep waking the CPU and the radio, which drains the battery.
  4. The tab is killed. Mobile browsers and operating systems discard background tabs that use a lot of memory. Chrome on desktop does it too under memory pressure. The user comes back to a reloaded page, and any unsaved draft is gone. If the tab itself runs out of memory, the user may see Chrome's "Aw, Snap!" error page or another browser's equivalent instead.

Check your understanding · Exercise 1

Which of these observations point to a memory leak? Select all that apply.

The mental model: reachable means alive

In JavaScript you never free memory yourself. There is no free() and no delete for objects. The engine frees an object when it can prove nobody can use it any more. To prove that, it uses one rule: an object stays alive if it can be reached from a root.

Think of a climbing wall with a few anchors bolted into the ceiling. Every climber hangs from an anchor, or from another climber who hangs from an anchor. Cut a rope and everyone below it falls, even if they are still tied to each other. The garbage collector is gravity: whatever no longer hangs from an anchor goes.

What counts as a root

A root is a starting point the engine always keeps. In a browser tab the main roots are:

  • The global object (window or globalThis) and everything on it, plus the top-level variables of every loaded module. Modules are never unloaded during the life of the page, so their variables live as long as the tab.
  • The call stack: local variables of every function that is running or paused, including async functions waiting on an await.
  • The document: the live DOM tree, starting at document. Every element on the page is reachable.
  • The browser's own registries: listeners attached to live targets, pending timers and their callbacks, pending promise reactions, active observers, open sockets, and requestAnimationFrame callbacks. These hold references to your functions until you remove them.

The last group is where most leaks hide. When you call setInterval(tick, 1000), the browser keeps tick alive, and tick keeps alive every variable it closes over. You never stored it anywhere, yet it is reachable.

Four roots across the top: window, document, the call stack, and the timer list. window reaches the store, which reaches the user. document reaches main, which reaches a list. The call stack is empty while the app is idle. The timer list still holds a tick function from a closed screen, which holds 4 MB of chart data: a leak. Below, two old objects point at each other but no root reaches them, so they are freed.windowdocumentcall stacktimer listempty when idlestoremaintickuserlistdatareachable: keptleak: screen closed,timer still holds itABunreachable: freed
The collector keeps whatever a root can reach, and nothing else. The chart data is kept only because a timer nobody cleared still holds its callback. The two old objects are freed even though they point at each other.

Mark and sweep, in two steps

Modern engines find live objects by tracing. Step one, mark: start at every root, follow every reference, and mark each object reached. Step two, sweep: every unmarked object is garbage, and its memory is reused.

Tracing has a nice property: cycles don't leak. A detached dialog element that points at its controller, which points back at the dialog, is freed as soon as nothing outside the pair points at either. Older reference-counting collectors (and old Internet Explorer, with its separate DOM and JS heaps) could leak cycles. Today's browsers don't.

That moves the question. You never ask "did I free this?". You ask "what still points at this?". In a heap snapshot, that chain of references from a root to your object is called the retainer path, and finding it is how every leak gets fixed.

Generations: why most garbage is cheap

Tracing the whole heap is expensive, so engines exploit a pattern seen in almost every program: most objects die young. A temporary array in a map call, the event object for a click, a parsed JSON chunk: they live for microseconds. A few objects, like the store, the router, and cached data, live for the whole session.

So engines split the heap in two. New objects go into a small young generation. It is collected often and quickly, because only the few survivors need copying out. Objects that survive a couple of young collections are promoted to the old generation, which is larger and collected less often with a slower mark, sweep, and compact.

New objects enter a small young generation. Of nine young objects, seven temporary ones and event objects die and are freed; two survive and are promoted to the larger old generation. The old generation holds long-lived objects: the store, the router, a cache, and leaked data from a closed screen. The young generation is collected often and fast; the old one less often, with mark, sweep, and compact.new objectsyoungtmpevttmptmprowtmpevttmpmapmost die youngpromotedoldstoreroutercacheleaked datacollected often, fastonly survivors copiedcollected less oftenmark, sweep, compact
Most objects die young, so collecting the young space is cheap. A leaked object survives, gets promoted, and makes every old-generation collection bigger. In V8 these collectors have long been the Scavenger and Mark-Compact; exact sizes and thresholds vary by engine and version.

In V8 (Chrome, Edge, Node.js), whose garbage collector project is called Orinoco, the young collector has long been the Scavenger and the old one Mark-Compact. Much of the old-generation work runs on helper threads or in small slices between tasks, so in a healthy app the main thread usually pauses for only a few milliseconds. Firefox's SpiderMonkey and Safari's JavaScriptCore use the same broad ideas with different details. Treat any exact size, threshold, or collector name as an engine detail that changes between versions.

Here is why this matters for leaks. A leaked object survives, so it gets promoted. The old generation grows, each old-generation collection has more to mark, and the main-thread parts of those collections take longer. A leak is not just wasted megabytes: it makes every future collection more expensive.

Check your understanding · Exercise 2

A component builds const data = { rows: bigArray } and passes () => console.log(data.rows.length) to setInterval. The component is later removed from the page, but the interval is never cleared. What is true? Select all that apply.

Leak 1: detached DOM held by JavaScript

A DOM element has two possible owners. The document owns it while it is on the page. Your JavaScript owns it whenever a variable, array, map, or closure points at it. Removing an element from the page only removes the first owner.

An element that is off the page but still referenced from JavaScript is a detached element. It can't be seen, can't be clicked, and can't be freed. DevTools labels these as Detached in heap snapshots, for example Detached HTMLDivElement.

One reference keeps the whole subtree

Elements point both ways: a child has parentNode, a parent has children. So if you hold one cell of a removed table, you can walk up to the table and down to every other cell. The engine sees the same thing, so it keeps the whole tree, plus everything attached to it: listeners on those nodes, data stored in their properties, and the image data behind any images.

That is why detached DOM leaks are often large. A product grid of 200 cards with images might take tens of MB. Keep one card's price label in a variable after the grid re-renders, and you may keep all of it.

Before a re-render, the page tree is body, then a ul list, then three li rows, and a rowById map points at each row. After the re-render, the list has three new rows on the page, but the map still points at the three old rows. The old rows are detached: off the page, still in memory, along with their children and images.before re-renderbodyullililirowById holds 3 rows,all on the pageafter re-renderbodyullililidetachedlililirowByIdold rows kept
Removing an element from the page removes only one of its owners. The map is the second owner, so the old rows stay in memory as detached DOM until the map lets go.

How it usually happens

  • Element caches. Code saves rows in an array or Map for quick lookup (rowsById.set(id, rowEl)), then the list re-renders with new elements. The old ones live on in the map.
  • Module-level variables. let lastOpenedDialog at the top of a module holds the last dialog forever, even after the route that showed it is gone.
  • Closures in long-lived listeners. A window scroll handler that mentions headerEl keeps the header alive after the page changes.
  • Third-party widgets. A chart, map, or editor library keeps internal references to the container it was mounted into. If you remove the container without calling the library's destroy(), the library keeps it.
  • Debug helpers. console.log(element) can keep the element alive while DevTools is open, because the console holds the logged value so you can expand it. This can make a leak appear only when DevTools is open, or confuse a measurement. Clear the console before taking snapshots.
ts
// Leaks: the map outlives every render
const rowById = new Map<string, HTMLElement>();

function renderRows(items: Item[]) {
  list.replaceChildren();
  for (const item of items) {
    const row = makeRow(item);
    rowById.set(item.id, row); // old rows never leave
    list.append(row);
  }
}

// Fixed: clear what you replace, or look up on demand
function renderRowsFixed(items: Item[]) {
  rowById.clear();
  list.replaceChildren();
  for (const item of items) {
    const row = makeRow(item);
    row.dataset.id = item.id;
    rowById.set(item.id, row);
    list.append(row);
  }
}
The leaky version keeps every row ever rendered whose id no longer appears. Clearing the map on each render, or finding rows with list.querySelector when needed, removes the second owner.

Frameworks help, but only inside their own world

React, Vue, Svelte, and Angular create and remove DOM nodes for you, and drop their own references when a component unmounts. React refs are set to null on unmount. So in framework code, detached DOM leaks almost always come from a reference outside the framework: a module variable, a global store, a cache, a third-party instance, or a listener on window that closes over a ref's value.

Check your understanding · Exercise 3

A modal is removed from the page on close. Which references would keep it, and its 5 MB image preview, in memory? Select all that apply.

Leak 2: listeners, timers, and observers left running

This is the most common leak in component apps. A component registers something with an object that lives longer than the component, and never unregisters it. The long-lived object keeps the callback; the callback keeps the component's data.

The pattern always has three parts: a long-lived target (window, document, a socket, a store, a shared event bus), a registration (addEventListener, subscribe, setInterval, observe), and a callback that closes over short-lived data. Remove any one of the three and the leak is gone.

A timeline of one session: page load, open the dashboard, leave, open it again, leave. The app shell and store live for the whole session. The dashboard component lives only while open, twice. An interval that is cleared on leave lives exactly as long as the dashboard. A resize listener with no cleanup, added on the first visit, lives until the end of the session, and a second one added on the second visit does too.app shellstoredashboardintervallistener 1listener 2loadopenleaveopenleave
Anything registered by a screen should end when the screen ends, like the interval here. The two listeners outlive both visits, and each one keeps its visit's dashboard data alive with it.

Every registration and its matching cleanup

If you call the left column, the right-hand cleanup must run when the owner goes away.
RegistrationWho holds the callbackCleanupCommon mistake
target.addEventListener(type, fn)The target's listener listremoveEventListener with the same function, or an AbortSignalPassing a new arrow function to remove, which removes nothing
setInterval(fn, ms)The browser's timer list, foreverclearInterval(id)Keeping the id in a variable that the cleanup can't see
setTimeout(fn, ms)The timer list, until it firesclearTimeout(id)A retry loop that schedules a new timeout from inside the callback
requestAnimationFrame(fn)The frame callback list, until the next framecancelAnimationFrame(id)An animation loop that reschedules itself after the component is gone
new ResizeObserver(fn).observe(el)The observer, and the browser while it observesobserver.disconnect()Creating a new observer on each render
store.subscribe(fn)The store's subscriber setCall the returned unsubscribe functionIgnoring the return value
new WebSocket(url) or new EventSource(url)The browser while the connection is openclose() and remove its handlersReconnect logic that keeps running after the screen closes
new BroadcastChannel(name)The browser while the channel is openchannel.close()One channel per component instance, never closed

The remove must match the add

removeEventListener only works if you pass the exact same function object, the same event type, and the same capture flag. window.removeEventListener("resize", () => layout()) removes nothing, because that arrow function is new. Code like this looks correct in review and leaks in production.

The cleaner tool is an AbortController. Pass its signal to every addEventListener call made by one owner, and call abort() once when the owner goes away. All those listeners are removed together, whatever functions they used. The same signal can cancel fetch calls, so one abort() also stops requests whose responses nobody will read.

ts
function mountTicker(el: HTMLElement, socket: WebSocket) {
  const controller = new AbortController();
  const { signal } = controller;

  window.addEventListener("resize", () => layout(el), { signal });
  socket.addEventListener("message", (e) => render(el, e.data), { signal });
  fetch("/api/ticker", { signal }).then(/* ... */);

  const timer = setInterval(() => refresh(el), 5000);
  const observer = new ResizeObserver(() => layout(el));
  observer.observe(el);

  return function unmount() {
    controller.abort(); // listeners and fetch
    clearInterval(timer);
    observer.disconnect();
  };
}
One owner, one cleanup function. Timers and observers don't take a signal, so they get their own lines.

Lab: navigate between routes and watch the heap

The lab below simulates an SPA with two routes. The dashboard route allocates about 4 MB of chart data, adds a resize listener on window, starts a 5 second polling interval, and creates a ResizeObserver on the app's layout container, which outlives the route. Pick which cleanups the route runs on exit and whether chart queries go into a module-level cache, then navigate away and back many times and watch the heap after each forced GC.

On leave, the route will:
Query cache:
Heap after a forced GC over 0 visits to the dashboard route. It is 40.0 MB against a baseline of 40 MB. 0 copies of the 4 MB chart data are still alive, 0 resize handlers still run on every resize, 0 polling intervals are still running, and the cache holds 0 entries.40140MB after GCvisits: 0 of 40
Each visit that skips any one cleanup keeps its whole 4 MB of chart data, because every callback closes over it. An unbounded cache adds a slower, steady climb of its own.

Heap 40.0 MB (baseline 40). Leaked chart copies: 0. Resize handlers per resize: 0. Polls per minute from dead screens: 0. Cache entries: 0.

Notice two things. First, missing any one cleanup is enough to keep the whole 4 MB, because every one of those callbacks closes over the chart data. Fixing two out of three doesn't give you two thirds of the memory back: it gives you nothing.

Second, the leaked listeners keep running. After 20 visits, a single resize runs 20 handlers, which shows up as a runtime performance problem long before the memory itself is noticed. The cache choice adds a third lesson: with no limit it climbs slowly on every visit, while an LRU cap stops it at 3 entries.

Check your understanding · Exercise 4

Which cleanups actually remove the listener added by window.addEventListener("scroll", onScroll, { passive: true, capture: true })? Select all that apply.

Leak 3: closures, caches, and stores that only grow

The first two leaks are about forgetting to unregister. The next group is subtler: the reference is on purpose, but it holds more than you meant, or for longer than you meant.

Closures that capture more than they use

A closure is a function plus the variables it can see from where it was created. If a small callback is kept for a long time, everything it can see may be kept too. A click handler that only needs an id but was created next to a 20 MB response can keep that response alive for as long as the handler lives.

Engines try to keep only the variables a function actually uses. But in V8, the closures created in one scope share a single context object, which holds every variable that any of them uses. So if one short-lived function in a scope uses report, a long-lived sibling from the same scope keeps report alive too, even if it never mentions it. Other engines differ in the details; the safe habit is the same everywhere.

Inside openReport, the variables id, total, and a 20 MB report live in one shared context object. Two functions were created in that scope: logSize, which used report and ran once, and an export handler that only reads id and total. The event bus keeps the export handler for the whole session, the handler points at the shared context, and so the 20 MB report stays alive.shared contextidtotalreport: 20 MBlogSizeran once, goneexport handlerreads id, totalevent busreport lives as longas the handler does
In V8, functions made in the same scope share one context. logSize is gone, but it put report into the context, and the export handler still holds that context. Create long-lived callbacks in a small scope that never saw the big object.
ts
async function openReport(id: string) {
  const report = await loadReport(id); // 20 MB
  renderTable(report.rows);
  const total = report.rows.length;

  // Short-lived: runs once, uses report
  queueMicrotask(() => logSize(report));

  // Long-lived: kept by the event bus for the whole session.
  // Shares the scope's context with the callback above, so in V8
  // it can keep `report` alive even though it only reads `total`.
  bus.on("export", () => exportCount(id, total));
}

// Safer: build long-lived callbacks in a small scope of their own
function makeExportHandler(id: string, total: number) {
  return () => exportCount(id, total);
}
// Inside openReport: bus.on("export", makeExportHandler(id, total));
Copy out the few values a long-lived callback needs (an id, a count) and create it in a function that never saw the big object.

Caches and maps without limits

A cache is a memory leak with a good reason. const cache = new Map() at module level, filled on every request and never trimmed, grows with every distinct key the user ever touches. In a session that visits 5,000 product pages, a cache of 40 KB responses holds 200 MB.

The same applies to things that don't look like caches: a memoized selector keyed by arguments, a list of "recently seen" ids, an undo history, a chat message list that only appends, a log buffer waiting to be sent, an analytics queue that fails to flush while offline. Each is a collection with no rule for removing things.

Decision How should a long-lived in-memory cache decide what to drop?

Size limit with least-recently-used eviction (LRU)

Keep at most N entries or N bytes

On each read, mark the entry as most recent

When full, drop the least recently used entry

Pros

  • Memory has a hard ceiling you can put in a budget
  • Hot entries stay; one-off visits fall out
  • Easy with a Map, which keeps insertion order

Cons

  • You must pick N
  • Counting entries is easy; counting bytes needs an estimate

Time to live (TTL)

Store a timestamp with each entry

Treat entries older than T as missing and delete them on read or on a sweep

Pros

  • Also limits staleness, not only size
  • Matches how server data goes out of date

Cons

  • No size ceiling: a burst of distinct keys inside T still grows without limit
  • Needs a sweep or entries that are never read again stay

Tie entries to an owner's lifetime

Clear the cache when its owner (a route, a document, a session) goes away

Or use a WeakMap keyed by the owner object

Pros

  • No tuning; memory follows the screen's lifetime
  • Hard to get wrong once the owner is clear

Cons

  • Nothing is reused across owners
  • Only works when there is a natural owner object

No limit

Add on every miss, never remove

Pros

  • Simplest code
  • Fine for a small, fixed key space, like 12 currency codes

Cons

  • Grows with the session; the classic slow leak
  • Every entry is also old-generation work for the GC

What we pick: Default to LRU with an entry cap, plus a TTL if the data goes stale. Use owner-scoped caches for per-screen data. Allow "no limit" only for small, bounded key sets, and write the bound in a comment. Data-fetching libraries such as TanStack Query already do this with garbage-collection timers for unused queries; the in-memory-caches lesson in Module 9 goes deeper.

Global stores that keep everything

A global store (Redux, Zustand, a normalized entity cache) is a root by design. Anything you put in it lives until you take it out. Stores grow when every screen adds its data and no screen removes it: every ticket the agent ever opened, every search result page, every draft.

The fix is a rule, not a tool. Decide which slices are session-wide (the user, settings, open tabs) and which belong to a screen (the ticket being viewed, the current search results). Clear screen data on leave, or evict entities nobody has looked at recently.

Never put DOM nodes, class instances with listeners, or huge raw responses in a store. The local versus global state lesson covers where state should live; this is the memory side of the same choice.

Third-party scripts

Analytics, chat widgets, A/B testing tools, ad tags, and session replay scripts run in your page's heap. You can't fix their code, but you pay for their memory. Session replay tools in particular record DOM changes and may buffer them. Some widgets keep every element they ever decorated.

  • Measure the page with and without each script, after an hour of scripted use, not just after load.
  • Call each library's teardown (destroy(), dispose(), unmount()) when the screen that uses it goes away.
  • Load widgets that don't need your DOM in an iframe. On most desktop browsers a cross-site iframe runs in its own process (see the browser architecture lesson), so its memory is not in your heap, and removing the iframe throws it all away.

Check your understanding · Exercise 5

A product page caches price formatters: formatters.set(locale + currency, new Intl.NumberFormat(...)) in a module-level Map with no eviction. Is this a leak worth fixing? Select all that apply.

Where SPAs leak: routes, effects, and requests in flight

In an SPA, a route change is the moment that should release a whole screen's memory: its components, its DOM, its data, its listeners. Most SPA leaks are something that survives that moment. If memory after visiting a route and leaving it is not back near where it started, something on that route has a reference held from outside.

React effects: setup and cleanup are a pair

In React, every subscription a component makes belongs in a useEffect (or useLayoutEffect), and the function the effect returns is its cleanup. React calls the cleanup before running the effect again with new dependencies, and when the component unmounts. If the effect returns nothing, React has nothing to call, and whatever the effect registered stays registered.

Sequence diagram of a React effect for a live price. On mount, React runs the setup, which subscribes to the price feed for AAPL and adds a focus listener on window with an abort signal. When the symbol changes to MSFT, React first runs the cleanup, which unsubscribes and calls abort, removing the listener, and then runs the setup again for MSFT. On unmount React runs the cleanup one last time.ReacteffectpriceFeedwindowmount: setupsubscribe(AAPL)addEventListenercleanup (AAPL)unsubscribe()abort()setup (MSFT)unmount: cleanup
Every setup is paired with a cleanup. React runs the cleanup before each re-run and on unmount, so at most one subscription and one listener exist at a time. With no returned cleanup, each change of symbol would add another.
tsx
function LivePrice({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    fetch(`/api/price/${symbol}`, { signal })
      .then((r) => r.json())
      .then((d) => setPrice(d.price))
      .catch((e) => { if (e.name !== "AbortError") throw e; });

    const unsubscribe = priceFeed.subscribe(symbol, setPrice);
    window.addEventListener("focus", () => priceFeed.resync(symbol), { signal });

    return () => {
      controller.abort(); // stops the fetch and the focus listener
      unsubscribe();
    };
  }, [symbol]);

  return <output>{price ?? "..."}</output>;
}
Changing symbol runs the cleanup for the old symbol first, then the setup for the new one. Unmounting runs the cleanup once more.

In development, React's Strict Mode deliberately runs setup, cleanup, and setup again when a component first mounts. It exists to catch exactly this bug: if your effect has no cleanup, you'll see two subscriptions, two intervals, or doubled log lines in development. Treat that as a leak report, not as noise to silence.

Vue has onUnmounted and the cleanup callback of watch; Svelte has onDestroy and functions returned from onMount; Angular has ngOnDestroy and takeUntilDestroyed for RxJS. The rule is the same in every framework: registrations made by a component are undone by that component.

Requests and async work still in flight

When the user leaves a route while a request is in flight, the pending promise keeps its .then callbacks alive, and those callbacks keep the component's closure alive until the response arrives. For a single fetch that is a short delay, not a leak. It becomes one when the work never ends: a polling loop, a retry with backoff that keeps rescheduling, a stream that is never closed, or a long-poll connection.

Aborting also fixes a correctness bug. Without it, a slow response for the old route can land after the new route rendered, and write old data into shared state. The request races lesson covers that side; for memory, the point is that an AbortSignal is a single switch that ends listeners, fetches, and your own async loops (check signal.aborted between steps).

Routes kept alive on purpose

Some apps keep inactive screens mounted so going back is instant: tabbed workspaces, "keep-alive" route caches in Vue and Angular, React apps that hide screens with CSS instead of unmounting them. That is a trade of memory for speed, which is fine if it is bounded. Keep the last few screens, not every screen ever opened, and evict the least recently used one.

Long lists are the other trap. A feed that appends every page the user scrolls through keeps every DOM node and every item. Virtualization keeps the DOM bounded to the visible rows, but the data array still grows; for very long sessions also drop pages far from the viewport and refetch them if the user scrolls back. The virtualization lesson goes into the details.

Check your understanding · Exercise 6

Which of these React effects leak when the component unmounts? Select all that apply.

WeakMap, WeakRef, and FinalizationRegistry

Everything so far is a strong reference: as long as it exists, its target stays alive. JavaScript also has three tools for references that don't keep their target alive. They solve real problems but are easy to misuse; the TC39 proposal that added WeakRef and FinalizationRegistry itself advises avoiding those two where possible. So it is worth knowing exactly what each one promises.

WeakMap and WeakSet: data attached to an object

A WeakMap is a map whose keys are held weakly. If nothing else references a key object, the GC may free it, and its entry vanishes with it. The value is held only as long as the key is alive. WeakSet is the same idea for a set of objects.

The use case is "extra data about an object I don't own": state for a DOM element, metadata for a library object, a computed result cached per input object. With a normal Map, your table would keep every key alive. With a WeakMap, the table follows the life of the keys.

ts
// Per-element state that dies with the element
const tooltipState = new WeakMap<Element, { timer: number; text: string }>();

function attachTooltip(el: Element, text: string) {
  tooltipState.set(el, { timer: 0, text });
}

// Memoize a derived value per input object
const totals = new WeakMap<Order, number>();
function orderTotal(order: Order) {
  let t = totals.get(order);
  if (t === undefined) {
    t = order.lines.reduce((sum, l) => sum + l.qty * l.price, 0);
    totals.set(order, t);
  }
  return t;
}
When an element or order object becomes unreachable, its entry can be collected. No cleanup code needed.

Limits to remember: keys must be objects (or non-registered symbols), not strings or numbers; you can't list the entries or ask for the size, because the answer would depend on when the GC ran. A value that points back at its own key is fine: the spec treats each entry as an ephemeron, so the key and value are freed together.

WeakRef: maybe-still-there

A WeakRef wraps one object without keeping it alive. ref.deref() returns the object if it is still around, or undefined if it was collected. It suits caches of objects that are expensive to rebuild but fine to lose, such as a decoded image or a parsed document the user might reopen.

The catch: you can't predict when deref() starts returning undefined. It may be milliseconds or never, because engines are free to keep objects longer. The spec does keep an object you deref() alive until the current synchronous job ends, so you can use it safely within that run of code. Code that works only because an object happened to be collected, or happened not to be, is a bug.

FinalizationRegistry: a callback you can't count on

A FinalizationRegistry lets you ask for a callback some time after an object is collected. It's meant for cleaning up things the GC doesn't know about, like removing a now-empty entry from a Map of WeakRefs, or releasing a WebAssembly allocation as a backup.

The spec does not require engines to call the callback at all: it may run late, or never (for example if the page is closed first). So never put required work in a finalizer: closing a socket, saving data, removing a listener, or releasing a lock. Those need explicit cleanup tied to a lifecycle. A finalizer is a safety net that logs "this was never cleaned up" in development, not a mechanism.

Comparison of Map, WeakMap, WeakRef, and FinalizationRegistry. Map holds its keys strongly, can be listed, and you decide when entries leave: use it for capped caches. WeakMap does not keep keys alive, cannot be listed, and the GC decides when entries go: use it for data attached to objects. WeakRef does not keep its target alive and the GC decides: use it for lossy caches, whose entries you can rebuild. FinalizationRegistry callbacks run late or never: only a debugging safety net.strong?listable?timinguse it forMapyesyesyou decidecapped cachesWeakMapnonoGC decidesdata per objectWeakRefnon/aGC decideslossy cachesFinalizationRegistrynonolate or neversafety net
Only a Map gives you control over when memory is released. The weak tools hand that decision to the garbage collector, which is fine for side data and rebuildable caches, and never fine for required cleanup.

Check your understanding · Exercise 7

Which uses of weak references are sound? Select all that apply.

Finding a leak with heap snapshots

Finding a leak is a repeatable process, not a guess: prove that memory grows with a specific action, find which objects survive that action, then follow their retainer path to the reference you forgot. Chrome DevTools is the usual tool; Firefox and Safari have similar memory tools with different names.

Step 1: confirm with a cheap signal

Open DevTools, then the Performance monitor (from the command menu). It shows live graphs of JS heap size, DOM nodes, and JS event listeners. Repeat the suspect action 10 times (open and close the ticket, go to the route and back). If DOM nodes or listeners climb by roughly the same amount every time and never fall, you have found the action that leaks.

Always collect garbage before reading a number: the Collect garbage button in the Memory panel's toolbar forces a full GC. Heap size without a GC includes garbage that simply hasn't been collected yet, which looks like a leak but isn't. Taking a heap snapshot also runs a GC first.

Step 2: the three-snapshot technique

A single snapshot tells you what's in memory, which is mostly things that should be there. The trick is to compare snapshots so that only what the action left behind remains. The common recipe uses three:

  1. Warm up. Do the action once and undo it, so one-time costs (lazy-loaded code, first-use caches, fonts) are paid before you start counting.
  2. Snapshot 1: baseline. Take a heap snapshot in the Memory panel.
  3. Repeat the action N times. For example, open and close the dialog 5 times, ending in the same state as the baseline. Then take snapshot 2.
  4. Repeat N more times. Take snapshot 3.
  5. Compare. Select snapshot 3 and use the "Objects allocated between Snapshot 1 and Snapshot 2" view. Those are objects created during the first batch that are still alive after the second batch. Anything there whose count is a multiple of N is a strong suspect.
Step 1 of 7

Warm up: open and close the ticket panel once, so lazy code, fonts, and first-use caches are loaded before you count anything.

Three heap snapshots, 0 taken so far. Snapshot 1 holds only the baseline. Snapshot 2 adds what the first batch of 5 opens left: 5 TicketPanel objects, 5 detached divs, and 1 SearchResults. Snapshot 3 still has the first batch's 5 TicketPanel and 5 detached divs, plus the second batch's 5 of each and its own SearchResults.snapshot 1not taken yetsnapshot 2not taken yetsnapshot 3not taken yet
Rows marked 1st were allocated between snapshots 1 and 2; rows marked 2nd between snapshots 2 and 3.

Why three? Snapshot 2 alone would include objects from the last action that are legitimately still alive (the most recent search results, the last route's cached data). Asking which objects from the first batch survived a whole second batch filters those out. What's left is memory that never comes back, no matter how often you repeat.

Step 3: read sizes and retainers

TermWhat it meansHow to use it
Shallow sizeMemory held by the object itselfUsually small; arrays and strings are the exceptions
Retained sizeMemory that would be freed if this object were freed, including everything only it keeps aliveSort by this to find the object that matters most
DistanceHow many references from the nearest rootLeaked objects often sit at a surprising distance through an unexpected path
RetainersThe objects that point at this object, up to a rootRead from the top: the first entry you recognise as yours is usually the bug
Detached prefixA DOM node not in the document but reachable from JSFilter the snapshot by Detached to list them

Click a suspect, for example one of 5 leaked Detached HTMLDivElement objects, and open the Retainers pane below. It shows a chain such as: the div, held by the context of a function, held by a listener in window's resize list. That chain tells you which closure and which addEventListener call to fix. Skip the engine's own entries and look for the first name from your own code.

For detached DOM specifically, Chrome 130 added a Detached elements profile type to the Memory panel. It lists the detached nodes that JavaScript still references, with node counts, without reading a whole snapshot. The console helper queryObjects(SomeClass) lists live instances of a constructor, which is a quick way to check "how many ChartController objects exist right now?".

Flowchart for triaging memory growth. Does memory keep growing? If no, it is high use or churn, not a leak. If yes, does it grow each time an action X repeats? If no, look for idle growth from timers, polls, or buffers. If yes, are there detached nodes in the snapshot diff? If yes, it is detached DOM: find the JavaScript reference. If no, does the listener count grow? If yes, a listener, timer, or observer is missing its cleanup. If no, look for a growing map, cache, or store that needs a cap or an owner.memory keeps growing?nonot a leakhigh use or churnyesgrows per repeat of X?noidle growthtimers, polls, buffersyesdetached nodes in diff?yesdetached DOMfind the JS referencenolistener count grows?yesmissing cleanuplistener, timer, observernogrowing map or storeadd a cap or an owner
Each question is answered with a cheap check: the Performance monitor for growth and listener counts, a snapshot comparison for detached nodes. Whatever the branch, the fix ends at a retainer path.

Step 4: make it a test

A leak you fixed once will come back. The cheapest guard is an automated loop that runs in CI: open a page in a headless browser, run the action N times, force GC, and fail if heap size or DOM node count after the loop exceeds the baseline by more than a margin. Playwright and Puppeteer can drive the loop and read metrics through the Chrome DevTools Protocol.

Meta's open-source MemLab automates the three-snapshot method: you describe the action and how to undo it, and it reports leaked objects grouped by retainer path. Teams use it on their main flows (open a conversation, close it) to catch regressions before release.

Check your understanding · Exercise 8

You open and close a panel 5 times between snapshots 1 and 2, and 5 more times before snapshot 3. In "objects allocated between 1 and 2" (viewed from snapshot 3) you find 5 TicketPanel objects and 1 SearchResults object. What do you conclude? Select all that apply.

Measuring in production and setting a memory budget

Lab tests find leaks in the flows you thought to script. Real users find the rest: the tab left open over a weekend, the account with 30,000 contacts, the browser extension that clones every node. To see those, measure memory in the field, and decide in advance how much is too much.

performance.measureUserAgentSpecificMemory()

The API designed for field measurement is performance.measureUserAgentSpecificMemory(), a draft from the Web Incubator Community Group (WICG). It returns a promise with an estimate of the bytes used by the page, including its iframes, related windows, and workers in the same process, with a breakdown by type and attribution. The number depends on the browser and its version, so compare it only with itself. At the time of writing it is available in Chromium-based browsers only, so feature-detect it.

It only works on a page that is cross-origin isolated (self.crossOriginIsolated is true). That requires two response headers on the document: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless). Isolation stops a page from measuring memory that might belong to other sites, but it also changes how your page can embed cross-origin resources and talk to popups, so it is a real project, not a header you add on a whim.

ts
async function sampleMemory() {
  if (!self.crossOriginIsolated) return;
  if (!("measureUserAgentSpecificMemory" in performance)) return;
  const result = await performance.measureUserAgentSpecificMemory();
  report("memory", {
    bytes: result.bytes,
    minutesOpen: Math.round(performance.now() / 60000),
    route: location.pathname,
  });
}

// Sample at random intervals, not on a fixed clock
function scheduleSample() {
  const meanMs = 5 * 60_000;
  const delay = -Math.log(Math.random()) * meanMs;
  setTimeout(() => sampleMemory().finally(scheduleSample), delay);
}
Random (exponential) gaps avoid always sampling right after the same periodic task. The promise can take a while to resolve: Chrome measures during a garbage collection and forces one only after a timeout (20 seconds at the time of writing).

Older code reads performance.memory (usedJSHeapSize and friends). It is non-standard, Chromium-only, covers only the JS heap of the current page, and its values are coarse on purpose. It can still show a trend in aggregate, but don't compare it across browsers or treat one reading as precise.

What to look at in the data

  • Memory against time open. Plot the median and 95th percentile bytes by "minutes since load". A healthy app rises in the first minutes, then goes flat. A leaking app keeps rising. This one chart is the best leak detector you have in production.
  • Growth by release. Compare the slope (MB per hour) between versions. A new release that doubles the slope has a new leak, even if every lab test passes.
  • Discards and reloads. On page load, document.wasDiscarded is true in Chromium if the browser discarded the tab earlier to save memory. A rising discard rate, especially on phones, is often the first user-visible sign.

Set a memory budget

A budget turns "use less memory" into numbers the team can test. A long-lived app needs three: how much it uses after load, how close to baseline it must return after a screen closes, and how fast it may grow during use. The numbers below are examples for a dashboard-style app, not standards; tune them to your users' devices.

Bar chart of two releases against a memory budget. After warm-up the budget is 100 MB and both releases use 92 MB. After 10 open and close loops the budget is 97 MB, about 5% above the warm-up level: release A, which leaks, uses 160 MB and release B, fixed, uses 95 MB. After one hour of use the budget is 120 MB: release A uses 410 MB and release B uses 118 MB.100after warm-upA 92, B 9297after 10 loopsA 160, B 95120after 1 hourA 410, B 118release A, leakingrelease B, fixeddashed: budget in MB
The first bar pair looks identical: a load-time check can't see a leak. Only the loop and the one-hour lines catch release A, which is why a long-lived app needs all three.
Example targets, not standards. Phones with 3 or 4 GB of RAM discard background tabs far sooner than a 32 GB laptop.
Budget lineExample targetHow it's checked
Heap after load and warm-upUnder 100 MB of JS heap on the main routeLab: snapshot after a scripted warm-up, in CI
Return to baselineWithin 5% of baseline after open-and-close of any screen, 10 timesLab: the scripted route loop with forced GC
Growth during useUnder 20 MB per hour at the 75th percentileField: slope of memory against time open
Cache ceilingsEach in-memory cache has a documented entry or byte capCode review and unit tests on the eviction rule
DOM sizeRoughly 1,400 elements or fewer on a typical screen, the long-quoted Lighthouse guidelineLab: count document.querySelectorAll("*").length after the loop

Plan for the tab being killed anyway

Even a leak-free app can be discarded when the device runs low. So long-lived apps should treat their in-memory state as disposable. Save drafts and unsent messages to IndexedDB or localStorage as the user types, put the current view in the URL, and listen for visibilitychange (when the page becomes hidden) to flush anything pending. Then a discard costs the user a reload, not their work.

Check your understanding · Exercise 9

Your team wants field memory data from performance.measureUserAgentSpecificMemory(). What is true? Select all that apply.

Using it in a system design interview

Memory rarely gets its own question. It comes up inside designs where the app stays open: a chat client, an email inbox, a news feed with infinite scroll, a collaborative editor, a stock dashboard, a video call. In those, a short, specific answer about memory signals that you've run such an app in production.

When to bring it up

  • When you describe the lifetime of the app. "Users keep this open all day, so memory has to stay flat; I'll come back to that." One sentence early, then a few minutes in the deep dive.
  • When you add a cache or a store. Say the eviction rule in the same breath: "normalized message store, capped at the last 2,000 messages per conversation, older pages refetched on scroll".
  • When you add real-time data. Sockets, polling, and subscriptions are registrations. Say who owns them and when they end.
  • When you add a long list. Virtualize the DOM and bound the data.

What to draw

Two pictures do most of the work. First, the component tree with lifetimes marked: which parts live for the session (app shell, store, socket connection) and which live per screen (the open conversation, its listeners, its cache slice). Arrows from per-screen parts to session parts are where cleanup is required. Second, a small chart of memory against time with a flat line, labelled with the budget.

How deep to go by level

LevelWhat a strong answer includes
JuniorKnows that listeners, timers, and subscriptions need cleanup; uses effect cleanup functions; knows the GC frees unreachable objects
MidNames the common leaks (detached DOM, closures, unbounded caches); uses AbortController; caps caches with LRU; can describe the heap snapshot workflow
SeniorDesigns ownership: every subscription belongs to a component or route; sets a memory budget; adds a scripted leak test in CI; plans for tab discard by persisting drafts
StaffField measurement with measureUserAgentSpecificMemory and the isolation cost it brings; growth-per-hour by release as a tracked metric; policy for third-party scripts and iframes; trade-offs of keep-alive screens across the product

Think first

You are designing a team chat web app that people keep open all week. Where can memory grow, and what's your plan for each?

Check your understanding · Exercise 10

In an infinite-scroll feed design, which statements show good memory thinking? Select all that apply.

Summary

  • Objects live while they are reachable from a root: globals and module variables, the call stack, the live DOM, and the browser's lists of listeners, timers, and observers. A leak is an unneeded object that is still reachable.
  • SPAs never get the free reset of a page load, so anything a screen forgets to release accumulates for the whole session.
  • The classic leaks: detached DOM held by JavaScript, listeners and timers on long-lived targets, closures that capture big objects, caches and stores with no eviction, and third-party widgets never destroyed.
  • Every registration needs a matching cleanup tied to an owner's lifetime. In React that is the effect's return function; AbortController removes many listeners and cancels fetches with one call.
  • Cap every cache (LRU, plus TTL where data goes stale). Use WeakMap for data attached to objects; never rely on FinalizationRegistry for required cleanup.
  • Find leaks with a repeated action and three heap snapshots, then read the retainer path of a leaked object to the reference you forgot.
  • Set a memory budget (heap after warm-up, return to baseline, growth per hour), check it with a scripted loop in CI, and watch memory against time open in production.
  • Even a leak-free app can be discarded on a busy phone: persist drafts and view state so a reload costs nothing.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is a memory leak in a JavaScript app, given that the language has a garbage collector?

Question 2

Users report the app gets slow after a few hours. How would you find out whether it's a memory leak, and where?

Question 3

How do you prevent leaks in a React codebase by design, not by debugging?

Question 4

When would you use WeakMap, WeakRef, or FinalizationRegistry?

Question 5

How would you monitor memory in production for a long-lived app?

Practice it

These design problems lean on memory in long-lived apps.

References

Read next