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);Output (verified)
without close(): rows still in memory with close(): rows freed
Memory
- snapshot 1: 18 MB
Take snapshot 1 on the home page, after the page has settled.
Read the steps as text
- Take snapshot 1 on the home page, after the page has settled.
- Open the report page and go back home, five times. Then take snapshot 2.
- Compare the snapshots. Five copies of the report's rows array were created and never freed.
- 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?
A node removed from the page is a detached node. It is freed only when nothing reachable points at it. If the component object is still referenced, for example by a listener, the table and all its children stay. “Not in the page” is the tempting but wrong test.
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.”
Follow-up What is the difference between shallow and retained size?
Follow-up What is a detached DOM node?
Follow-up Why not watch performance.memory to find leaks?
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.
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
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
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
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.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?