⚡ Web performance

Browser memory leaks: finding them with heap snapshots

How to recognize a memory leak in a web page, the most common causes such as listeners and detached nodes, and how to track them down with the Memory panel.

A page's performance is not only about loading. A web application that stays open for a long time, such as a dashboard, a management tool, a webmail client or a single-page site where the user browses for hours, can grow steadily slower for no apparent reason. The cause is often a memory leak: objects that are no longer needed but that the JavaScript engine cannot free, because something still references them.

How memory works in JavaScript

JavaScript manages memory automatically. The garbage collector starts from certain roots, such as the global object and variables in use, and follows every reference: whatever is reachable stays in memory, whatever is not is freed. A leak, then, is not an engine bug but a forgotten reference in the code that keeps useless objects reachable.

Typical symptoms are:

  • the page gets slower over time or after many internal navigations;
  • garbage collection pauses become more frequent and longer, visible in a profile as recurring blocks;
  • on low-memory devices, the browser reloads the tab when the user returns to it.

The most common causes

Listeners and timers never removed

A component that, when mounted, adds a handler on window or starts a setInterval and does not remove them when destroyed lives on. The listener keeps a reference to the function, the function to the component, the component to its data.

// Avoid
function mountPanel(el) {
  window.addEventListener('resize', () => update(el));
}

// Better
function mountPanel(el) {
  const controller = new AbortController();
  window.addEventListener('resize', () => update(el),
                          { signal: controller.signal });
  return () => controller.abort(); // call on unmount
}

Using AbortController lets you remove in one go every listener tied to the same signal.

Detached DOM nodes

An element removed from the page but still referenced by a JavaScript variable, a map or a cache cannot be freed, and neither can its whole subtree. It is common with lists rebuilt on every update, when the old elements remain stored in an array.

Caches that grow without limit

A Map used as a results cache, to which entries are added but never removed, grows for the entire session. The solutions are a size limit with eviction of the oldest entries, or a WeakMap when the key is an object: entries disappear by themselves when the key is no longer referenced elsewhere.

Closures that hold on to too much

An inner function keeps access to the variables of the scope it was created in. If a long-lived event handler is created inside a function that manipulates a large data set, it can retain that data even if it does not use it directly.

Subscriptions left open

WebSocket connections, observers (IntersectionObserver, MutationObserver, ResizeObserver) and subscriptions to state stores must be closed or disconnected when the component using them goes away.

Finding the leak with DevTools

The Memory panel in Chrome DevTools provides the necessary tools. The most reliable method is comparing heap snapshots:

  1. open the page and bring the application to a starting state;
  2. take a first snapshot;
  3. repeat the suspicious action several times, for example opening and closing a panel or navigating between two sections, always returning to the starting state;
  4. take a second snapshot;
  5. select the second one and choose the comparison view against the first.

If the action did not leak, memory would return to its initial level after each cycle. Object classes whose count grows in proportion to the repetitions are the suspects.

Snapshot view What it is for
Summary Objects grouped by constructor, with counts and sizes
Comparison Differences between two snapshots
Containment Structure starting from the roots
Retainers (bottom pane) The chain of references keeping an object alive

The retainers pane is the most valuable: once you select a suspicious object, it shows who is holding it, all the way to the root. The chain often ends in a listener, a global variable or a cache, pointing precisely at what to fix. Filtering the summary with the word Detached immediately reveals detached DOM nodes.

To watch the trend over time, recording allocations on the timeline shows blue bars for memory allocated and still alive and grey bars for memory already freed: blue bars piling up with each repetition indicate the leak.

Prevention

In my work, leaks are better prevented by habits than by investigations:

  • every function that adds a listener, a timer or an observer returns a clean-up function;
  • frameworks provide component unmount hooks: always use them to release resources;
  • caches have an explicit limit;
  • in tests of critical features, repeating an action many times and checking memory surfaces problems before users do.

In short

  • A memory leak is a forgotten reference that keeps useless objects alive.
  • Typical causes are listeners and timers, detached nodes, unbounded caches and open subscriptions.
  • Compare heap snapshots before and after repeating the suspicious action.
  • The retainers pane shows the chain holding the object.
  • Prevention: clean-up functions, proper unmounting and bounded caches.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also