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.
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.
- 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.
- 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.
- 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.
- 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 (
windoworglobalThis) 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
asyncfunctions waiting on anawait. - 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
requestAnimationFramecallbacks. 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.
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.
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.
How it usually happens
- Element caches. Code saves rows in an array or
Mapfor 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 lastOpenedDialogat 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
windowscroll handler that mentionsheaderElkeeps 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.
// 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);
}
}
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.
Every registration and its matching cleanup
| Registration | Who holds the callback | Cleanup | Common mistake |
|---|---|---|---|
target.addEventListener(type, fn) | The target's listener list | removeEventListener with the same function, or an AbortSignal | Passing a new arrow function to remove, which removes nothing |
setInterval(fn, ms) | The browser's timer list, forever | clearInterval(id) | Keeping the id in a variable that the cleanup can't see |
setTimeout(fn, ms) | The timer list, until it fires | clearTimeout(id) | A retry loop that schedules a new timeout from inside the callback |
requestAnimationFrame(fn) | The frame callback list, until the next frame | cancelAnimationFrame(id) | An animation loop that reschedules itself after the component is gone |
new ResizeObserver(fn).observe(el) | The observer, and the browser while it observes | observer.disconnect() | Creating a new observer on each render |
store.subscribe(fn) | The store's subscriber set | Call the returned unsubscribe function | Ignoring the return value |
new WebSocket(url) or new EventSource(url) | The browser while the connection is open | close() and remove its handlers | Reconnect logic that keeps running after the screen closes |
new BroadcastChannel(name) | The browser while the channel is open | channel.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.
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();
};
}
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.
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.
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.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));
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?
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.
symbol would add another.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>;
}
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.
// 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;
}
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.
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:
- 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.
- Snapshot 1: baseline. Take a heap snapshot in the Memory panel.
- 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.
- Repeat N more times. Take snapshot 3.
- 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.
Warm up: open and close the ticket panel once, so lazy code, fonts, and first-use caches are loaded before you count anything.
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
| Term | What it means | How to use it |
|---|---|---|
| Shallow size | Memory held by the object itself | Usually small; arrays and strings are the exceptions |
| Retained size | Memory that would be freed if this object were freed, including everything only it keeps alive | Sort by this to find the object that matters most |
| Distance | How many references from the nearest root | Leaked objects often sit at a surprising distance through an unexpected path |
| Retainers | The objects that point at this object, up to a root | Read from the top: the first entry you recognise as yours is usually the bug |
Detached prefix | A DOM node not in the document but reachable from JS | Filter 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?".
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.
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);
}
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.wasDiscardedistruein 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.
| Budget line | Example target | How it's checked |
|---|---|---|
| Heap after load and warm-up | Under 100 MB of JS heap on the main route | Lab: snapshot after a scripted warm-up, in CI |
| Return to baseline | Within 5% of baseline after open-and-close of any screen, 10 times | Lab: the scripted route loop with forced GC |
| Growth during use | Under 20 MB per hour at the 75th percentile | Field: slope of memory against time open |
| Cache ceilings | Each in-memory cache has a documented entry or byte cap | Code review and unit tests on the eviction rule |
| DOM size | Roughly 1,400 elements or fewer on a typical screen, the long-quoted Lighthouse guideline | Lab: 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
| Level | What a strong answer includes |
|---|---|
| Junior | Knows that listeners, timers, and subscriptions need cleanup; uses effect cleanup functions; knows the GC frees unreachable objects |
| Mid | Names the common leaks (detached DOM, closures, unbounded caches); uses AbortController; caps caches with LRU; can describe the heap snapshot workflow |
| Senior | Designs 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 |
| Staff | Field 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?
Messages: the store keeps a window per conversation (say the last 500 messages) and drops older pages; scrolling up refetches them. Conversations not viewed for 30 minutes are evicted from memory entirely.
DOM: the message list is virtualized, so DOM size is bounded by the viewport, not the history.
Subscriptions: one socket for the app, owned by the shell. Each open conversation subscribes to its channel on mount and unsubscribes on unmount; typing and presence timers are cleared with the conversation.
Media: decoded images and previews are released when their messages leave the window; object URLs from URL.createObjectURL are revoked.
Verification: a CI test switches between 20 conversations 10 times and asserts heap and DOM node count return within 5% of baseline; in production we chart memory against time open per release.
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;
AbortControllerremoves many listeners and cancels fetches with one call. - Cap every cache (LRU, plus TTL where data goes stale). Use
WeakMapfor data attached to objects; never rely onFinalizationRegistryfor 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?
The garbage collector frees objects that can't be reached from a root: the global object and module variables, the call stack, the live DOM, and the browser's registries of listeners, timers, and observers.
A leak is an object the app no longer needs that is still reachable, usually through a reference someone forgot: a listener on window, an interval never cleared, a closure kept by a long-lived object, a cache with no eviction.
So the fix is never "free the memory" but "cut the reference". In an SPA it matters more because there is no page reload to reset the heap.
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?
First, reproduce with a repeated action: pick a likely flow (open and close a ticket, switch routes) and watch DOM nodes, listeners, and JS heap in the Performance monitor, forcing GC before each reading. Steady growth per repetition confirms a leak and names the action.
Then use three heap snapshots: warm up, snapshot, repeat the action N times, snapshot, repeat N more times, snapshot. In the third, view objects allocated between the first and second. Constructors whose counts are multiples of N are the leak.
Open the retainers of one leaked object and read up to the first name from our code: that's the listener, closure, or cache to fix. Then add a scripted loop to CI so it doesn't come back, and check the field data (memory against time open) after the release.
Question 3
How do you prevent leaks in a React codebase by design, not by debugging?
Every registration lives in an effect that returns its cleanup. A shared AbortController per effect removes all its listeners and cancels its fetches with one call; timers and observers get explicit clear and disconnect calls. Strict Mode's double mount in development surfaces missing cleanups early.
No DOM nodes, class instances with listeners, or raw large responses in global stores or module variables. Per-screen data is cleared on leave or evicted by recency.
Every cache has a cap (LRU, plus TTL where data goes stale). Third-party widgets get their destroy() called on unmount. A CI test loops through main flows and asserts memory returns to baseline.
Question 4
When would you use WeakMap, WeakRef, or FinalizationRegistry?
WeakMap for data attached to objects you don't control, like state per DOM element or memoized results per input object: the entry dies with the key, so the table can't leak.
WeakRef for caches of things that are expensive to rebuild but fine to lose, with a rebuild path when deref() returns undefined.
FinalizationRegistry only as a backup or a debugging aid, such as logging objects collected without being disposed. Its callbacks may run late or never, so required cleanup (closing sockets, saving data, removing listeners) must be explicit. None of these fix a forgotten listener: a handler held by window is strongly reachable regardless.
Question 5
How would you monitor memory in production for a long-lived app?
Where the page is cross-origin isolated (COOP same-origin plus COEP require-corp or credentialless), sample performance.measureUserAgentSpecificMemory() at random intervals in Chromium and report bytes with time open and route. Isolation has its own costs for embeds and popups, so weigh it.
Without isolation, the non-standard performance.memory still shows trends in aggregate, and document.wasDiscarded on load reveals how often the browser killed the tab.
Chart median and p95 memory against minutes open, per release, and alert when the growth slope rises. Pair it with a lab budget: heap after warm-up, return to baseline after closing screens, and capped caches.
Practice it
These design problems lean on memory in long-lived apps.
- Design an infinite scroll listMedium
- Design an avatar image cropperMedium
- Design an image lightbox with zoomMedium
- Design a virtualized listHard
- Design a PDF viewerHard
- Design live comments for a sports streamHard
- Design a TikTok-style vertical video feedHard
- Design DiscordHard
- Design a web-based terminalHard
- Design an IoT dashboard for thousands of sensorsHard
- Design a log viewer for millions of linesHard
- Design a customer support ticket dashboardHard
- Design a web photo editorHard
- Design a 3D product configuratorHard
References
- Memory management (MDN) Reachability, garbage collection, and the weak data structures.
- Fix memory problems (Chrome DevTools) Heap snapshots, allocation timelines, and detached DOM.
- Record heap snapshots (Chrome DevTools) Summary, comparison, and retainer views.
- Monitor your web page's total memory usage with measureUserAgentSpecificMemory() (web.dev) The field API and its cross-origin isolation requirement.
- WeakRef (MDN) Includes the notes on avoiding reliance on GC timing.
- FinalizationRegistry (MDN) Why cleanup callbacks are not guaranteed.
- Trash talk: the Orinoco garbage collector (V8 blog) Generations, the Scavenger, and concurrent marking in V8.
- MemLab (Meta) Automated leak detection with the snapshot comparison method.
Read next
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Inside the browserHow a modern browser splits its work across processes and threads, why your JavaScript shares one main thread with layout, paint, and input, and how the event loop, workers, and OffscreenCanvas decide whether a page stays responsive.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- In-memory cachesData your app keeps as live JavaScript objects in the tab's memory, keyed and bounded, so that going back, switching tabs, or opening the same thing twice is instant: the fastest cache you have, and the easiest one to let grow out of control.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.