Engrain by ByteRush

JS.12.04 Senior· 12 minCore — asked often

Finding and fixing memory leaks in a single-page app

The common leaks (listeners, timers, closures, detached DOM, unbounded caches), how to prove them with heap snapshots and the allocation timeline, and how to stop them on route change.

Comes up in: Debugging, Web fundamentals

Learn#

Tanvi’s admin app crashes the browser tab after two hours of work. Each visit to the report page leaves its data behind, and the tab’s memory climbs from 80 MB to 2 GB.

A leak in JavaScript is memory that stays reachable after you are done with it (JS.12.03). The usual causes: event listeners on window or document never removed, timers never cleared, subscriptions never closed, caches that only grow, and DOM nodes removed from the page but still referenced in a variable.

import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
setFlagsFromString("--expose-gc");
const gc = runInNewContext("gc");
const bus = new EventTarget(); // lives as long as the app, like window

function openReport() {
  const rows = new Array(1_000_000).fill("row"); // a big table
  const onResize = () => console.log(rows.length);
  bus.addEventListener("resize", onResize);
  return { rows, close: () => bus.removeEventListener("resize", onResize) };
}
async function check(label, closeIt) {
  let report = openReport();
  const ref = new WeakRef(report.rows);
  if (closeIt) report.close();
  report = null; // the user left the report page
  await new Promise((r) => setTimeout(r, 0));
  gc();
  console.log(label, ref.deref() ? "rows still in memory" : "rows freed");
}
await check("without close():", false);
await check("with close():", true);
This example uses Node.js, so it runs in our checks rather than in your browser.

Output (verified)

without close(): rows still in memory
with close(): rows freed
Proving a leak with two heap snapshotsStep 1 of 4

Memory

  • snapshot 1: 18 MB

Take snapshot 1 on the home page, after the page has settled.

Read the steps as text
  1. Take snapshot 1 on the home page, after the page has settled.
  2. Open the report page and go back home, five times. Then take snapshot 2.
  3. Compare the snapshots. Five copies of the report's rows array were created and never freed.
  4. Open the retainers of one array: window → resize listener → onResize closure → rows. The listener is the leak.

Predict#

Predict first

A component removes its table from the page but keeps it in this.table. After the component is gone, is the table freed?

Interview angle#

Say it like this — about 30 seconds

“Common SPA leaks are listeners on window or document, timers, subscriptions, unbounded caches, and detached DOM nodes held in variables. To find one, I repeat an action, such as opening and closing a page, take heap snapshots before and after, and compare them. I look for objects whose count grows with each repeat, then follow the retainer chain to the root holding them. The fix is cleanup on unmount: remove listeners, clear timers, and abort requests.”

Stays on your device. Nothing is uploaded.
Follow-up What is the difference between shallow and retained size?
Shallow size is the object itself. Retained size is all the memory that would be freed if that object were freed.
Follow-up What is a detached DOM node?
A node removed from the document but still referenced from JavaScript, so it cannot be freed.
Follow-up Why not watch performance.memory to find leaks?
It is non-standard, Chromium-only, and its numbers are rounded and delayed. Use heap snapshots or measureUserAgentSpecificMemory.

Practice#

On your own

mountReport leaks a listener, a timer and a subscription. Make destroy() clean up all three.

Done when

  • After destroy(), dispatching “resize” calls nothing.
  • After destroy(), the refresh timer never fires again.
  • After destroy(), the store has no subscribers left from this widget.
Your solution
Ctrl/⌘ + Enter

Remember#

Try to answer each card before you open it. Then add them to your reviews.

Name four common causes of leaks in a single-page app.

Unremoved listeners, uncleared timers, open subscriptions, unbounded caches.

What is retained size?

Memory that would be freed if this object were freed.

What is a retainer?

An object holding a reference that keeps another object alive.

How do you confirm a leak with snapshots?

Repeat an action several times, then compare snapshots for objects that grow with each repeat.

Go deeper

Four layers. Stop at the one your interview needs; each ends with a quick check.

The core ideaEvery round
In a single-page app, pages are never unloaded, so every route change must clean up. Pair every addEventListener with a remove, every setInterval with a clear, every subscribe with an unsubscribe. In React, the effect’s cleanup function does this.
Check yourself: What should the cleanup of a component do?

Remove listeners, clear timers, unsubscribe, and abort requests it started.

How it works underneathSenior rounds
A heap snapshot lists every live object. In Chrome DevTools, take one, repeat the action, take another, and use the Comparison view. Sort by retained size, then read the retainer chain to see what holds the object Source: Chrome DevTools: Record heap snapshots.
Check yourself: In a retainer chain, what are you looking for?

The first link your code controls, such as a listener or a cache.

Edge cases and trapsWhere people slip

The allocation timeline records allocations over time; bars that stay blue are objects still alive at the end Source: Chrome DevTools: Allocation timeline.
A detached node appears in snapshots under “Detached”. Closures leak when they capture a big variable and live on in a long-lived listener.

Check yourself: What does the allocation timeline show that a snapshot does not?

When objects were allocated, and which of them are still alive.

In productionStaff-level answers

performance.memory is non-standard and imprecise. performance.measureUserAgentSpecificMemory() is the modern API, but it works only in Chromium on cross-origin isolated pages.
Bound every cache with a size limit or use a WeakMap keyed by objects. Add a test that opens and closes a page 20 times and checks memory.

Check yourself: Which API measures page memory in production, and what does it need?

performance.measureUserAgentSpecificMemory(); it needs a cross-origin isolated page.

Finished the five steps? Mark it, and its cards join your reviews.

Last checked 2026-10-31. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.

Interview questions this answers

  • What are common causes of memory leaks in a single-page app? How do you find them?