cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Memory management & garbage collection

Learn how JavaScript allocates, uses, and releases memory, how mark-and-sweep finds unreachable objects, and how common leaks happen.

By the end, you can
  • 01
    Explain the lifecycleDescribe allocate, use, and release without claiming JavaScript frees memory manually.
  • 02
    Trace reachabilityStart from roots, follow references, and predict what mark-and-sweep can collect.
  • 03
    Spot common leaksFix long-lived listeners, intervals, closures, caches, and detached DOM node references.

Memory you can reason about, not manually free

The stack-and-heap lesson gave you a careful mental model: variables can point to heap objects, and reachability decides whether an object is still needed. The closures lesson showed that functions can keep variables alive after their creator returns. Memory management joins those ideas and applies them to real frontend code.

Plain definition

Memory management is the process of allocating values, using them while they are reachable, releasing unneeded references, and letting the JavaScript engine garbage-collect objects that can no longer be reached from roots.

You never call free() for ordinary JavaScript objects. You design code so unneeded objects stop being reachable: remove event listeners, clear timers, cap caches, and stop storing DOM nodes you removed. The exact garbage-collection run remains the engine's decision.

Allocate, use, release

STEP THROUGH
Real-life analogyA backpack you pack and unpack

The useful habit is not “free the object.” It is “unpack the paths that keep this object useful.” Once no root can reach it, the object is eligible for cleanup.

In real life: Put a notebook in the backpack
In JavaScript: Allocate an object and keep a reference
In real life: Read from the notebook during class
In JavaScript: Use the object while it is reachable
In real life: Remove the notebook when the class ends
In JavaScript: Release the reference you no longer need
In real life: Lost-and-found decides when to recycle unclaimed items
In JavaScript: The engine chooses when garbage collection actually runs

Where the analogy stops: A backpack has a visible owner and a predictable cleanup time. JavaScript reachability can include globals, closures, host queues, and DOM structures, and garbage collection is not scheduled by your code.

The memory lifecycle in JavaScript
PhaseWhat your code doesCareful wording
AllocateCreate objects, arrays, functions, DOM nodes, and closures as code runs.You do not reserve raw memory manually.
UseRead and write reachable values through variables, object properties, callbacks, or DOM references.Reachable data must stay alive.
ReleaseRemove references you no longer need: clear slots, remove listeners, clear timers, cap caches.This makes objects eligible; GC timing is still automatic.

Step through this small feature. It creates a preview object, reads and mutates it, then removes the local reference. The console output is predictable; the later collection moment is not.

Follow allocate, use, release
Step 0 of 7Ready
Your turn: follow the blue line

Follow the memory lifecycle: allocate an object, use it, remove a reference, and let reachability decide what can be collected later.

Running in
  1. script
Next: line 9
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function openPreview() {  let preview = { title: "Cart", rows: ["hat", "bag"] };  const label = preview.title.toUpperCase();  preview.rows.push("coat");  preview = null;  return label;} 
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Line 5 is the subtle line. preview = null is useful because it removes one path, but it is not a command to the garbage collector. If another root still reached the object, it would stay alive.

Roots and reachability

MODEL

Garbage collectors start from roots: values the engine and host know must stay alive. From those roots, the collector follows references. If a root can reach an object, the object must be kept. If no root can reach it, the object is eligible for collection.

Real-life analogyA family WhatsApp group

Start with people already in the group and follow shared contacts. If you can reach someone through that chain, keep the object. If not, the object is unreachable in this model.

In real life: People already in the group
In JavaScript: Roots such as globals and active stack frames
In real life: A contact shared in a message
In JavaScript: Object properties that reference other objects
In real life: Nobody reachable through the group
In JavaScript: An unreachable object
In real life: Removing unused information
In JavaScript: Garbage collection

Where the analogy stops: Messages do not control when a phone cleans storage. JavaScript references change while code runs.

Common roots and why they matter
RootWhy it can retain memory
Global objectGlobals and module-level variables can keep values reachable for the page lifetime.
Current call stackLocal variables in active functions are roots while those functions are running.
Active closures and handlersA stored callback keeps the variables it closes over reachable.
Host structuresEvent targets, timer queues, pending tasks, and the DOM can retain callbacks or nodes.

Generational collectors add an optimization on top of this model: most objects die young, so engines usually check young generations more often and promote survivors to older generations. That overview is enough here; engine internals differ, and normal code still reasons with reachability.

Mark and sweep, including cycles

SIMULATION

A simple reference-counting collector counts incoming references and frees an object when the count reaches zero. That idea breaks down with cycles: two objects can point at each other even after the rest of the program has lost them. Mark-and-sweep avoids that problem by starting from roots instead of trusting local counts.

Run a mark-and-sweep simulation
Step 0 of 15Ready
Your turn: follow the blue line

Step through a real JavaScript mark-and-sweep simulation. Watch roots mark reachable objects and an unreachable cycle get swept.

Running in
  1. script
Next: line 14
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function markAndSweep(heap, roots) {  const marked = new Set();  const worklist = [...roots];  while (worklist.length > 0) {    const id = worklist.pop();    if (!id || !heap[id] || marked.has(id)) continue;    marked.add(id);    for (const ref of heap[id].refs) worklist.push(ref);  }  const swept = Object.keys(heap).filter((id) => !marked.has(id));  return { marked: [...marked], swept };}   app: { refs: ["modal"] },  modal: { refs: ["user"] },  user: { refs: ["profile"] },  profile: { refs: [] },  cycleA: { refs: ["cycleB"] },  cycleB: { refs: ["cycleA"] },};const roots = ["app"];const result = markAndSweep(heap, roots);console.log(result.marked.join(", "));console.log(result.swept.join(", "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

In the graph, cycleA and cycleB point at each other. A pure reference count would see one incoming reference for each object. The mark phase starts from app, never reaches the cycle, and the sweep phase can collect the whole unreachable island. This is still a simulation: it shows eligibility, not the browser's exact GC moment.

Leaks from listeners, timers, closures, and caches

PLAYGROUND

A leak is not “an object exists.” A leak is “something long-lived still reaches data the app no longer needs.” Frontend leaks often involve host roots: event targets, timers, retained closures, and caches that never shrink.

Mount and unmount with or without cleanup
Component retention simulationPop out in the code editor (opens in a new tab)JavaScript
const roots = ["app", "window", "timerQueue"]; function createMountedHeap() {  return {    app: { refs: ["component"] },    window: { refs: ["resizeHandler"] },    timerQueue: { refs: ["intervalCallback"] },    component: { refs: ["bigRows"] },    resizeHandler: { refs: ["bigRows"] },    intervalCallback: { refs: ["bigRows"] },    bigRows: { refs: [] },  };} function reachableIds(heap) {  const seen = new Set();  const worklist = [...roots];  while (worklist.length) {    const id = worklist.pop();    if (!heap[id] || seen.has(id)) continue;    seen.add(id);    worklist.push(...heap[id].refs);  }  return [...seen].filter((id) => !roots.includes(id));} function simulateUnmount(cleanup) {  const heap = createMountedHeap();  heap.app.refs = [];  if (cleanup) {    heap.window.refs = [];    heap.timerQueue.refs = [];  }  return reachableIds(heap);} console.log(simulateUnmount(false).join(", "));console.log(simulateUnmount(true).length);
Retained after unmountcleanup off

3 reachable simulated objects

  • intervalCallbackretained
  • bigRowsretained
  • resizeHandlerretained
Try it yourself

The component was removed, but window and timerQueue still reach callbacks that close over bigRows.

This is a simulated heap graph. It counts objects reachable from long-lived roots after a component unmounts; it does not inspect the browser's garbage collector.

Without cleanup, the simulated window listener and timerQueue still reach callbacks that close over bigRows. With cleanup, those roots are cut and the retained count drops to zero.

Listener cleanup with AbortControllerPop out in the code editor (opens in a new tab)JavaScript
const panel = document.querySelector("#listener-panel");const controller = new AbortController();const rows = ["Ada", "Lin"]; function renderRows() {  console.log(rows.join(", "));} panel.addEventListener("click", renderRows, { signal: controller.signal });panel.click();controller.abort();panel.click();console.log("listener removed");

A listener on a long-lived target such as window can retain its callback and everything the callback closes over. Use removeEventListener, a listener option such as { once: true }, or an AbortController signal when the same cleanup action removes several listeners.

Clear intervals that keep callbacks alivePop out in the code editor (opens in a new tab)JavaScript
const rows = ["Ada", "Lin", "Grace"];const intervalId = setInterval(() => {  console.log(rows.length);  clearInterval(intervalId);  console.log("interval cleared");}, 0);

A setInterval callback remains reachable from the timer queue until you call clearInterval. If it closes over a large array, DOM node, or old component state, that data remains reachable too.

Capture only what the callback needsPop out in the code editor (opens in a new tab)JavaScript
function makeLeakyHandler() {  const bigReport = ["sales", "inventory", "returns"];  return () => console.log(bigReport.length);} function makeSmallHandler() {  const rowCount = ["sales", "inventory", "returns"].length;  return () => console.log(rowCount);} makeLeakyHandler()();makeSmallHandler()();

The first handler captures a whole array. The second handler computes the primitive count and captures only that. The best fix is often to move large data out of the closure, not to avoid closures altogether.

Bound a strong Map cachePop out in the code editor (opens in a new tab)JavaScript
const cache = new Map(); function remember(key, value) {  cache.set(key, value);  if (cache.size > 2) {    const oldestKey = cache.keys().next().value;    cache.delete(oldestKey);  }} remember("Ada", { visits: 1 });remember("Lin", { visits: 2 });remember("Grace", { visits: 3 });console.log([...cache.keys()].join(", "));
Use WeakMap for object metadataPop out in the code editor (opens in a new tab)JavaScript
const metadata = new WeakMap();let card = { id: "promo" };metadata.set(card, { measured: true }); console.log(metadata.has(card));card = null;console.log("WeakMap keys are weakly held");

A normal Map strongly holds keys and values, so size caps, deletion, and clearing are real cleanup tools. A WeakMap is useful when metadata should not keep object keys alive by itself. You still cannot observe the exact moment a key is collected.

Which situations retain memory?
  • `button.addEventListener("click", save, { once: true })`
  • A resize listener on window closes over page data and is never removed.
  • A setInterval callback closes over rows and the ID is never cleared.
  • Metadata keyed by DOM nodes lives in a WeakMap.
  • A normal Map cache deletes its oldest entry after two items.
  • An array stores a removed DOM node for debugging and is never cleared.
Try it yourself
0 of 6 correct

Sort each card by whether it is cleaned, retained until explicit cleanup, or controlled with weak/bounded storage.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Detached DOM nodes

RUN CONTEXT

A DOM node is detached when it is no longer inside the document tree. It can still be reachable from JavaScript. This matters in UI code that stores old nodes in arrays, debug globals, module caches, event callbacks, or closure state after calling DOM methods like remove().

The runnable example below uses the lesson's RunContext page, which provides #leak-card. It removes that card from document, then proves the array still holds it.

Detached from the document, still referencedPop out in the code editor (opens in a new tab)JavaScript
const retained = [];const card = document.querySelector("#leak-card"); retained.push(card);card.remove(); console.log(document.contains(card));console.log(retained.length);console.log(retained[0].textContent.trim()); retained.length = 0;console.log(retained.length);

The important pair is document.contains(card) === false and retained.length === 1. The node is not in the document, but it is still reachable. Clearing the array removes this snippet's extra reference.

Finding leaks with Chrome DevTools

Normal page JavaScript cannot reliably ask “did GC run now?” Use tooling for leak investigations. In Chrome DevTools, open the Memory panel and collect evidence over repeated actions: mount and unmount a component, navigate away and back, or open and close a modal several times.

  1. Heap snapshot: take one snapshot, perform the suspected leak action, take another, then switch to the Comparison view to find constructors whose retained counts grew.
  2. Detached nodes: use the snapshot class filter for detached DOM terms and the Detached elements profile type when you want a focused list of elements retained by JavaScript references.
  3. Allocation instrumentation on timeline: record allocations over time, select an interval, and inspect objects allocated in that interval that are still live at the end of the recording.
  4. Allocation sampling: use it for lower-overhead, longer-running sessions when you need an approximate allocation breakdown by stack.
Memory measurement APIs are not a GC oracle

performance.memory is non-standard and Chrome-specific, so treat it as a diagnostic hint. performance.measureUserAgentSpecificMemory() is the newer browser measurement API, but it requires cross-origin isolation headers before a normal page can use it. Neither lets app code depend on deterministic collection.

Memory API cautionJavaScript
// Chrome-only and non-standard; do not build logic on this.
console.log(performance.memory);

// Needs cross-origin isolation headers before it is available.
if (crossOriginIsolated && "measureUserAgentSpecificMemory" in performance) {
  const result = await performance.measureUserAgentSpecificMemory();
  console.log(result.bytes);
}

Common misconceptions

What to say instead
MisconceptionMore accurate wording
Setting a variable to null frees memory immediatelyIt removes one reference. Other paths may exist, and collection timing is automatic.
Cycles always leakPure reference counting has trouble with cycles, but mark-and-sweep collects unreachable cycles.
Closures are leaksClosures are normal. A leak happens when a long-lived function keeps data you no longer need.
performance.memory is a portable answerIt is non-standard and Chrome-specific; do not build app logic on it.
A detached node is goneIt is gone from the document tree, but any JavaScript reference can still retain it.

“If I set it to null, memory is gone.”

You removed one path. Other roots may still reach the object, and collection timing remains automatic.

“Cycles always leak.”

Unreachable cycles are collectible with mark-and-sweep. A cycle leaks when a root still reaches it.

“Closures are bad for memory.”

Closures are normal. Long-lived callbacks that capture unnecessary data are the pattern to fix.

“DevTools numbers are exact proof of a single object being freed.”

Use snapshots, comparisons, and allocation recordings to find trends and retained paths, not to write app logic around exact GC moments.

Practice: reason from roots

5 EXERCISES
Exercise 1 · Warm-upPredict the lifecycle output

Type the exact value printed by the program.

Starter codePop out in the code editor (opens in a new tab)JavaScript
function openPreview() {
  let preview = { title: "Cart", rows: ["hat", "bag"] };
  const label = preview.title.toUpperCase();
  preview.rows.push("coat");
  preview = null;
  return label;
}
console.log(openPreview());

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeSweep an unreachable cycle

    Which object IDs does the simulated sweep return?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function markAndSweep(heap, roots) {
      const marked = new Set();
      const worklist = [...roots];
      while (worklist.length > 0) {
        const id = worklist.pop();
        if (!id || !heap[id] || marked.has(id)) continue;
        marked.add(id);
        for (const ref of heap[id].refs) worklist.push(ref);
      }
      return Object.keys(heap).filter((id) => !marked.has(id));
    }
    const heap = {
      app: { refs: ["modal"] },
      modal: { refs: [] },
      cycleA: { refs: ["cycleB"] },
      cycleB: { refs: ["cycleA"] },
    };
    console.log(markAndSweep(heap, ["app"]).join(", "));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the listener cleanup bug

      Name one cleanup technique that would prevent the listener from retaining rows.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function mount(button, rows) {
        function render() {
          console.log(rows.length);
        }
        button.addEventListener("click", render);
        return () => {
          // add cleanup here
        };
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeStop the interval

        What cleanup call stops this interval?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const id = setInterval(() => {
          console.log("tick");
        }, 1000);
        // write the cleanup call

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeClassify a detached node

          After card.remove(), what does document.contains(card) print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const retained = [];
          const card = document.querySelector("#leak-card");
          retained.push(card);
          card.remove();
          console.log(document.contains(card));
          console.log(retained.length);

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz: memory management and leaks

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which sentence best defines JavaScript memory management?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this lifecycle code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function openPreview() {
                let preview = { title: "Cart", rows: ["hat", "bag"] };
                const label = preview.title.toUpperCase();
                preview.rows.push("coat");
                preview = null;
                return label;
              }
              console.log(openPreview());

              Choose an answer to see the explanation.

            3. Question 3 of 8Which values are roots in the reachability model?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does the simulated sweep print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function markAndSweep(heap, roots) {
                const marked = new Set();
                const worklist = [...roots];
                while (worklist.length > 0) {
                  const id = worklist.pop();
                  if (!id || !heap[id] || marked.has(id)) continue;
                  marked.add(id);
                  for (const ref of heap[id].refs) worklist.push(ref);
                }
                return Object.keys(heap).filter((id) => !marked.has(id));
              }
              const heap = {
                app: { refs: ["modal"] },
                modal: { refs: [] },
                cycleA: { refs: ["cycleB"] },
                cycleB: { refs: ["cycleA"] },
              };
              console.log(markAndSweep(heap, ["app"]).join(", "));

              Choose an answer to see the explanation.

            5. Question 5 of 8Why did old reference-counting collectors struggle with cycles?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which cleanup stops a long-lived window listener from retaining its closure?

              Choose an answer to see the explanation.

            7. Question 7 of 8What does this interval snippet print before it clears itself?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const rows = ["Ada", "Lin", "Grace"];
              console.log(rows.length);

              Choose an answer to see the explanation.

            8. Question 8 of 8A DOM node was removed from the document, but an array still stores it. What is true?

              Choose an answer to see the explanation.

            Key takeaways

            • JavaScript allocates objects as code runs and automatically collects unreachable ones later.
            • Reachability starts at roots: globals, active stack frames, host queues, closures, handlers, and DOM structures.
            • Mark-and-sweep collects unreachable cycles because it traces from roots instead of relying on local reference counts.
            • Leaks usually come from long-lived roots: listeners, intervals, closures, unbounded caches, and retained detached DOM nodes.
            • Use Chrome DevTools Memory tools to investigate; do not build app behavior around exact GC timing.

            One rule to keep:
            If a root can still reach an object, JavaScript must keep it; when no root can reach it, the object is eligible, not synchronously freed.

            Up next: WeakRef & FinalizationRegistry, where the same nondeterministic timing warning becomes the main design constraint.

            CompleteFrontend Clear concepts. Working examples.