Skip to content
Codeloom
JavaScript

How to Find and Fix JavaScript Memory Leaks

Learn how to detect, diagnose, and fix JavaScript memory leaks using Chrome DevTools heap snapshots, allocation timelines, and common leak patterns.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • The most common causes of memory leaks in JavaScript
  • How to use Chrome DevTools to find leaks
  • How to interpret heap snapshots and allocation timelines
  • Proven patterns to prevent leaks in production code

Prerequisites

  • JavaScript fundamentals
  • Basic understanding of garbage collection

Memory leaks are one of the most insidious bugs in JavaScript applications. They do not crash your app immediately. Instead, they slowly degrade performance until the tab becomes unresponsive or the Node.js process runs out of memory. Finding them requires understanding how JavaScript manages memory and knowing the right tools.

How JavaScript Memory Works

JavaScript uses automatic garbage collection. The garbage collector periodically identifies objects that are no longer reachable from the root (the global object, the current call stack, and active closures) and frees their memory. A memory leak happens when your code holds references to objects that are no longer needed, preventing the garbage collector from reclaiming them.

The key concept is reachability. If an object can be reached through any chain of references starting from the root, it stays in memory. A leak is just an unintended reference that keeps something alive.

The Five Most Common Leak Patterns

1. Forgotten Event Listeners

This is the single most common source of leaks in browser applications:

function setupHandler() {
  const largeData = new Array(1_000_000).fill("x");

  document.addEventListener("click", function handler() {
    console.log(largeData.length);
  });
}

setupHandler();

The handler closure captures largeData. Even if you never need largeData again, it stays in memory as long as the click listener is registered. With SPAs that mount and unmount components, this compounds quickly.

The fix: always remove listeners when they are no longer needed:

class Component {
  #controller = new AbortController();

  mount() {
    document.addEventListener(
      "click",
      () => this.handleClick(),
      { signal: this.#controller.signal }
    );

    window.addEventListener(
      "resize",
      () => this.handleResize(),
      { signal: this.#controller.signal }
    );
  }

  unmount() {
    this.#controller.abort();
  }

  handleClick() { /* ... */ }
  handleResize() { /* ... */ }
}

Using AbortController lets you remove all listeners with a single call. No need to keep references to individual handler functions.

2. Detached DOM Nodes

When you remove a DOM element but keep a JavaScript reference to it, the entire subtree stays in memory:

let cachedElement = null;

function cacheElement() {
  cachedElement = document.getElementById("large-table");
}

function removeElement() {
  document.getElementById("large-table")?.remove();
}

After calling both functions, the table has been removed from the DOM but cachedElement still references it. The entire table and all its children remain in memory.

The fix: null out references when removing elements:

function removeElement() {
  document.getElementById("large-table")?.remove();
  cachedElement = null;
}

Or use WeakRef if you need a non-preventing reference:

let cachedRef = null;

function cacheElement() {
  const el = document.getElementById("large-table");
  cachedRef = new WeakRef(el);
}

function getElement() {
  return cachedRef?.deref();
}

3. Closures Capturing More Than Intended

Closures capture their entire lexical scope, not just the variables they reference. Some engines optimize this, but it is not guaranteed:

function createProcessor() {
  const hugeConfig = loadHugeConfig();
  const cache = new Map();

  return {
    process(key) {
      if (cache.has(key)) return cache.get(key);
      const result = expensiveCalculation(key);
      cache.set(key, result);
      return result;
    },
    clearCache() {
      cache.clear();
    },
  };
}

The hugeConfig might be kept alive even if only cache is used. Restructure to limit what closures capture:

function createProcessor() {
  const config = extractNeededFields(loadHugeConfig());
  const cache = new Map();

  return {
    process(key) {
      if (cache.has(key)) return cache.get(key);
      const result = calculate(key, config);
      cache.set(key, result);
      return result;
    },
    clearCache() {
      cache.clear();
    },
  };
}

4. Unbounded Caches and Collections

Maps, Sets, and arrays that grow without bound are a classic leak:

const requestLog = [];

function logRequest(req) {
  requestLog.push({
    url: req.url,
    timestamp: Date.now(),
    headers: { ...req.headers },
    body: req.body,
  });
}

In a Node.js server handling thousands of requests per second, this array will consume all available memory.

The fix: use bounded data structures:

class BoundedQueue {
  #items = [];
  #maxSize;

  constructor(maxSize = 1000) {
    this.#maxSize = maxSize;
  }

  push(item) {
    this.#items.push(item);
    if (this.#items.length > this.#maxSize) {
      this.#items.shift();
    }
  }

  getAll() {
    return [...this.#items];
  }
}

const requestLog = new BoundedQueue(5000);

Or use WeakMap when caching data associated with objects:

const metadataCache = new WeakMap();

function getMetadata(element) {
  if (metadataCache.has(element)) {
    return metadataCache.get(element);
  }

  const metadata = computeMetadata(element);
  metadataCache.set(element, metadata);
  return metadata;
}

When the element is garbage collected, its entry in the WeakMap is automatically cleaned up.

5. Forgotten Timers and Intervals

Intervals that are never cleared keep their callback and its closure alive:

function startPolling(url) {
  const data = { results: [] };

  setInterval(async () => {
    const response = await fetch(url);
    const json = await response.json();
    data.results.push(json);
    updateUI(data);
  }, 5000);
}

The fix: always store and clear interval IDs:

function startPolling(url) {
  const data = { results: [] };
  let intervalId = null;

  function start() {
    intervalId = setInterval(async () => {
      const response = await fetch(url);
      const json = await response.json();
      data.results.push(json);
      updateUI(data);
    }, 5000);
  }

  function stop() {
    if (intervalId !== null) {
      clearInterval(intervalId);
      intervalId = null;
    }
  }

  start();
  return { start, stop };
}

Finding Leaks with Chrome DevTools

Heap Snapshots

The most powerful tool for finding leaks is the heap snapshot comparison:

  1. Open DevTools, go to the Memory tab
  2. Take a Heap snapshot (baseline)
  3. Perform the action you suspect is leaking (e.g., navigate to a page and back)
  4. Take another heap snapshot
  5. Select the second snapshot and change the view to Comparison

The comparison view shows objects that were allocated between the two snapshots and are still retained. Sort by Size Delta to find the biggest retainers.

The Three-Snapshot Technique

For more accurate results:

  1. Take snapshot 1 (baseline)
  2. Perform the suspect action
  3. Take snapshot 2
  4. Perform the same action again
  5. Take snapshot 3
  6. Compare snapshot 3 to snapshot 2

Objects that accumulate between snapshots 2 and 3 are very likely leaks, because one-time initialization costs are already captured in snapshot 2.

Allocation Timeline

For finding leaks in real time:

  1. In the Memory tab, select Allocation instrumentation on timeline
  2. Start recording
  3. Perform the actions you suspect are leaking
  4. Stop recording

Blue bars that remain (never become gray) represent allocations that were never garbage collected. Click on them to see what object was allocated and trace its retention path.

Finding Leaks in Node.js

In Node.js, you can take heap snapshots programmatically:

const v8 = require("v8");

function takeHeapSnapshot() {
  const filename = `heap-${Date.now()}.heapsnapshot`;
  const snapshotPath = v8.writeHeapSnapshot(filename);
  console.log(`Heap snapshot written to ${snapshotPath}`);
}

process.on("SIGUSR2", () => {
  takeHeapSnapshot();
});

Send SIGUSR2 to the process to capture a snapshot, then load it in Chrome DevTools for analysis.

For monitoring memory over time:

function logMemoryUsage() {
  const usage = process.memoryUsage();
  console.log({
    rss: `${(usage.rss / 1024 / 1024).toFixed(1)} MB`,
    heapUsed: `${(usage.heapUsed / 1024 / 1024).toFixed(1)} MB`,
    heapTotal: `${(usage.heapTotal / 1024 / 1024).toFixed(1)} MB`,
    external: `${(usage.external / 1024 / 1024).toFixed(1)} MB`,
  });
}

setInterval(logMemoryUsage, 10000);

If heapUsed grows steadily over time without leveling off, you have a leak.

Automated Leak Detection

You can build automated tests to catch memory leaks:

async function checkForLeaks(action, iterations = 10) {
  if (global.gc) global.gc();
  const before = process.memoryUsage().heapUsed;

  for (let i = 0; i < iterations; i++) {
    await action();
  }

  if (global.gc) global.gc();
  const after = process.memoryUsage().heapUsed;

  const growth = after - before;
  const perIteration = growth / iterations;

  console.log({
    totalGrowth: `${(growth / 1024).toFixed(1)} KB`,
    perIteration: `${(perIteration / 1024).toFixed(1)} KB`,
  });

  return growth;
}

Run Node.js with --expose-gc to enable manual garbage collection for accurate measurements.

Prevention Patterns

Use WeakMap and WeakSet for Object-Keyed Caches

const computedStyles = new WeakMap();

function getCachedStyle(element) {
  if (!computedStyles.has(element)) {
    computedStyles.set(element, window.getComputedStyle(element));
  }
  return computedStyles.get(element);
}

Use FinalizationRegistry for Cleanup

const registry = new FinalizationRegistry(resourceId => {
  console.log(`Cleaning up resource: ${resourceId}`);
  releaseExternalResource(resourceId);
});

function createResource() {
  const resource = allocateExternalResource();
  const wrapper = { use() { return resource.data; } };
  registry.register(wrapper, resource.id);
  return wrapper;
}

Scope Variables Tightly

function processData(items) {
  const result = [];

  for (const item of items) {
    const transformed = transform(item);
    result.push(transformed);
  }

  return result;
}

Block-scoped const and let declarations inside loops are released as soon as the block exits, unlike var which is function-scoped.

Wrapping Up

Memory leaks in JavaScript come down to unintended references that prevent garbage collection. The five most common sources are forgotten event listeners, detached DOM nodes, excessive closure captures, unbounded caches, and forgotten timers. Chrome DevTools heap snapshots and the three-snapshot comparison technique are your primary diagnostic tools. On the prevention side, use AbortController for listener cleanup, WeakMap/WeakSet for object-keyed caches, and always clear intervals and timeouts when they are no longer needed.