JavaScript Interview Prep
Performance & Optimization

Memory Leaks

The Silent Killer That Crashes Your Tab in an Hour

LinkedIn Hook

Your app feels snappy on launch. Twenty minutes in, the fan spins up. An hour later the tab crashes with "Aw, Snap!".

That's not bad luck. That's a memory leak growing 2MB per minute while you look the other way.

JavaScript has automatic garbage collection, so most devs assume "memory" is someone else's problem. Until an interviewer asks: "Name 5 causes of memory leaks and how you'd detect each in DevTools." Silence.

In this lesson I walk through the 6 classic leak patterns — accidental globals, forgotten timers, detached DOM nodes, closures retaining large scopes, un-removed event listeners, circular references — and show you exactly how to hunt them with Chrome's Memory tab. Plus WeakMap, WeakRef, and FinalizationRegistry for the advanced round.

If your app slows down the longer it runs — this lesson is the fix.

Read the full lesson -> [link]

#JavaScript #Performance #MemoryLeaks #InterviewPrep #ChromeDevTools #WebDevelopment #Frontend


Memory Leaks thumbnail


What You'll Learn

  • What a memory leak actually is, and how mark-and-sweep garbage collection decides what to free
  • The 6 most common leak patterns in JavaScript and the exact fix for each
  • How to hunt leaks with Chrome DevTools (Heap Snapshot, Allocation Timeline, Performance Monitor)
  • When to reach for WeakMap, WeakRef, and FinalizationRegistry

The Hotel Analogy

Think of memory like a hotel. When guests (objects) check in, they get a room (memory allocation). When they check out, the room is cleaned and made available (garbage collection). A memory leak is when guests have left but the front desk still thinks they're staying — the room is occupied forever, and eventually the hotel is full with no one actually using the rooms.

What Is a Memory Leak?

A memory leak happens when your application allocates memory but never releases it, even though the data is no longer needed. JavaScript uses automatic garbage collection (mark-and-sweep), but the GC can only free memory that has no references pointing to it. If you accidentally keep a reference alive, the GC can't touch it.

Common Cause 1: Accidental Globals

// BAD — accidental global variable
function processData() {
  results = []; // forgot 'let' or 'const' — creates window.results
  for (let i = 0; i < 100000; i++) {
    results.push({ id: i, data: new Array(1000).fill("x") });
  }
  // results lives forever on window — never garbage collected
}

// GOOD — properly scoped
function processData() {
  const results = []; // scoped to function — GC'd after function returns
  for (let i = 0; i < 100000; i++) {
    results.push({ id: i, data: new Array(1000).fill("x") });
  }
  return results;
}

// BEST — use strict mode to catch accidental globals
"use strict";
function processData() {
  results = []; // ReferenceError: results is not defined
}

Common Cause 2: Forgotten Timers and Intervals

// BAD — interval never cleared, references keep growing
function startPolling() {
  const hugeData = new Array(1000000).fill("leak");

  setInterval(() => {
    // This closure captures hugeData — it can never be GC'd
    console.log(hugeData.length);
    // Even if you navigate away or remove the component,
    // this interval keeps running and holding hugeData
  }, 1000);
}

// GOOD — store the ID and clear when done
function startPolling() {
  const hugeData = new Array(1000000).fill("leak");

  const intervalId = setInterval(() => {
    console.log(hugeData.length);
  }, 1000);

  // Clear when no longer needed
  return function cleanup() {
    clearInterval(intervalId);
  };
}

// Usage in a component lifecycle
const stopPolling = startPolling();
// Later, when unmounting:
stopPolling();

Common Cause 3: Detached DOM Nodes

// BAD — DOM node removed from tree but still referenced in JS
const elements = {};

function addElement() {
  const div = document.createElement("div");
  div.id = "temp-element";
  document.body.appendChild(div);
  elements.tempDiv = div; // stored reference
}

function removeElement() {
  const div = document.getElementById("temp-element");
  document.body.removeChild(div);
  // div is removed from DOM but elements.tempDiv still holds it!
  // The entire DOM subtree cannot be GC'd
}

// GOOD — remove the JS reference too
function removeElement() {
  const div = document.getElementById("temp-element");
  document.body.removeChild(div);
  delete elements.tempDiv; // release the reference
}

Common Cause 4: Closures Retaining Large Scopes

// BAD — closure captures entire scope including largeData
function createProcessor() {
  const largeData = new Array(1000000).fill("data");
  const config = { threshold: 0.5 };

  return function process(input) {
    // Only uses config, but largeData is also captured
    return input > config.threshold;
  };
}

const processor = createProcessor();
// largeData (millions of entries) lives forever because
// the closure's scope chain includes it

// GOOD — restructure to avoid capturing unnecessary variables
function createProcessor() {
  const config = { threshold: 0.5 };

  return function process(input) {
    return input > config.threshold;
  };
  // largeData was never in this scope — nothing extra captured
}

// ALTERNATIVE — null out large references
function createProcessor() {
  let largeData = new Array(1000000).fill("data");
  const result = processAll(largeData);
  largeData = null; // allow GC even though closure still exists

  return function getResult() {
    return result;
  };
}

Common Cause 5: Event Listeners Not Removed

// BAD — listeners accumulate on every call
function setupHandler() {
  const data = fetchHugeDataset();

  document.getElementById("btn").addEventListener("click", () => {
    console.log(data); // captures data
  });
  // If setupHandler() is called multiple times,
  // each call adds a NEW listener holding its own data copy
}

// GOOD — use AbortController for clean removal
function setupHandler() {
  const controller = new AbortController();
  const data = fetchHugeDataset();

  document.getElementById("btn").addEventListener("click", () => {
    console.log(data);
  }, { signal: controller.signal });

  // Clean up all listeners at once
  return () => controller.abort();
}

// GOOD — named function for manual removal
function setupHandler() {
  const data = fetchHugeDataset();

  function handleClick() {
    console.log(data);
  }

  const btn = document.getElementById("btn");
  btn.addEventListener("click", handleClick);

  return () => btn.removeEventListener("click", handleClick);
}

Common Cause 6: Circular References

// Circular reference — objects reference each other
function createCircular() {
  const objA = { name: "A" };
  const objB = { name: "B" };
  objA.ref = objB;
  objB.ref = objA;
  // Modern GC (mark-and-sweep) handles this fine
  // But older reference-counting GC couldn't free these
}

// Where it still matters: DOM + JS circular references
function createLeak() {
  const div = document.createElement("div");
  const obj = {
    element: div,       // JS -> DOM
    data: new Array(100000)
  };
  div.customData = obj; // DOM -> JS (circular!)
  document.body.appendChild(div);

  // Even after removing from DOM:
  document.body.removeChild(div);
  // Both div and obj are still alive because they reference each other
}

Detecting Memory Leaks with Chrome DevTools

// Step 1: Open DevTools -> Memory tab
// Step 2: Take a Heap Snapshot (baseline)

// Step 3: Perform the suspected leaking action multiple times
for (let i = 0; i < 100; i++) {
  document.getElementById("leaky-button").click();
}

// Step 4: Take another Heap Snapshot
// Step 5: Compare snapshots — look for:
//   - Objects allocated between snapshots that weren't freed
//   - "Detached" DOM trees (search "Detached" in snapshot)
//   - Growing object counts

// Allocation Timeline method:
// 1. Memory tab -> select "Allocation instrumentation on timeline"
// 2. Click Record
// 3. Perform actions
// 4. Stop recording
// 5. Blue bars = allocated, gray bars = freed
//    Persistent blue bars = potential leaks

// Performance Monitor (real-time):
// 1. Ctrl+Shift+P -> "Show Performance Monitor"
// 2. Watch "JS heap size" — should be sawtooth pattern
//    (allocate -> GC collects -> allocate -> GC collects)
//    If it only goes UP — you have a leak

WeakRef and WeakMap Solutions

// WeakMap — keys are weakly held (GC can collect them)
const cache = new WeakMap();

function processElement(element) {
  if (cache.has(element)) {
    return cache.get(element); // cached result
  }

  const result = expensiveComputation(element);
  cache.set(element, result);
  // If element is removed from DOM and no other references exist,
  // both the key AND value are automatically GC'd
  return result;
}

// WeakRef — weak reference to an object
function createCache() {
  const cache = new Map();

  return {
    set(key, value) {
      cache.set(key, new WeakRef(value));
    },
    get(key) {
      const ref = cache.get(key);
      if (!ref) return undefined;

      const value = ref.deref(); // returns object or undefined if GC'd
      if (!value) {
        cache.delete(key); // clean up stale entry
        return undefined;
      }
      return value;
    }
  };
}

// FinalizationRegistry — callback when object is GC'd
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Object with ID ${heldValue} was garbage collected`);
  // Clean up associated resources
});

function trackObject(obj, id) {
  registry.register(obj, id);
  // When obj is GC'd, the callback fires with id
}

Memory Leaks visual 1


Common Mistakes

  • Assuming "use strict" or ESM strict-by-default will catch every accidental global — it won't catch assignments to properties of window/globalThis (e.g., window.cache = {}) which are just as leaky.
  • Removing a DOM node with removeChild and thinking the node is gone — if any JS variable, array, or object property still references it, the entire subtree stays in memory as a "detached DOM tree".
  • Treating WeakMap as a drop-in Map replacement — WeakMap keys must be objects (not strings or numbers), it's not iterable, and there's no .size property.

Interview Questions

Q: What is a memory leak in JavaScript and how does garbage collection work?

A memory leak occurs when allocated memory is no longer needed but cannot be freed because references to it still exist. JavaScript uses mark-and-sweep garbage collection: the GC starts from "roots" (global object, currently executing functions' local variables), marks all objects reachable from roots, and sweeps (frees) all unmarked objects. Leaks happen when unnecessary objects remain reachable.

Q: Name 5 common causes of memory leaks and how to prevent each.

  1. Accidental globals — use "use strict" and always declare variables. 2) Forgotten timers/intervals — always store interval IDs and clear them. 3) Detached DOM nodes — remove JS references when removing from DOM. 4) Closures capturing large scopes — restructure to avoid unnecessary captures. 5) Event listeners not removed — use removeEventListener or AbortController, especially in SPA component lifecycles.

Q: What is the difference between WeakMap and Map regarding garbage collection?

In a Map, keys are strongly held — the Map prevents GC from collecting them. In a WeakMap, keys are weakly held — if no other reference to the key exists, the GC can collect both the key and its associated value. This makes WeakMap ideal for caching metadata about objects without preventing their cleanup. WeakMap keys must be objects (not primitives).

Q: How do you detect memory leaks using Chrome DevTools?

Open DevTools -> Memory tab. Take a baseline Heap Snapshot, perform the suspected leaking action many times, then take a second snapshot and compare. Look for growing object counts and search for "Detached" to find detached DOM trees. For continuous monitoring, use the Performance Monitor (Ctrl+Shift+P -> "Show Performance Monitor") and watch the JS heap size — a healthy app shows a sawtooth pattern (allocate -> GC -> allocate -> GC). If it only climbs, you have a leak.

Q: What is the difference between WeakRef and a regular reference?

A regular reference keeps an object alive — the GC can't collect it as long as that reference exists. A WeakRef holds its target weakly: the GC is free to collect the target whenever it wants. Call ref.deref() to read it — you get the object if it's still alive, or undefined if it's been collected. WeakRef is useful for caches where you want to keep a value around opportunistically but don't want to prevent cleanup.


Quick Reference — Cheat Sheet

MEMORY LEAKS — 6 COMMON CAUSES + FIXES

1. Accidental globals      -> "use strict", always use let/const
2. Forgotten timers        -> store ID, clearInterval/clearTimeout
3. Detached DOM nodes      -> delete JS references after removeChild
4. Closures w/ large scope -> restructure, null out references
5. Unremoved listeners     -> AbortController / removeEventListener
6. Circular DOM<->JS refs  -> break the cycle on teardown

WEAK COLLECTIONS
  WeakMap  -> key must be object; GC'd when key has no strong refs
  WeakSet  -> same, but set of objects (no values)
  WeakRef  -> ref.deref() -> object or undefined
  FinalizationRegistry -> callback fires after GC

DETECTION IN CHROME DEVTOOLS
  Memory tab -> Heap Snapshot (baseline -> action -> compare)
  Memory tab -> Allocation Timeline (persistent blue = leak)
  Perf Monitor -> JS heap size (sawtooth = healthy, rising = leak)
  Search "Detached" in snapshot -> detached DOM trees

Previous: Dependency Injection -> Inverting Control for Testable Code Next: Lazy Loading -> Only Fetch What the User Actually Needs


This is Lesson 13.1 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page