DevAcademy
HomeBlogsClosures, Scope, and Memory: How the Garbage Collector Decides What to Keep
javascriptmemory management & garbage collectionscope

Closures, Scope, and Memory: How the Garbage Collector Decides What to Keep

Everyone learns closures as "a function that remembers its outer variables." But there's a question most tutorials skip: why does that memory stick around instead of getting cleared like a normal local variable would?

Dev AcademyDev Academy
August 11, 20263 min read50

The answer lives at the intersection of two things JavaScript devs rarely connect — closures and garbage collection.

A quick refresher on closures

A closure happens when a function keeps access to variables from its outer scope, even after that outer function has finished running.

function outer() {
  let count = 0;
  return function increment() {
    count++;
    console.log(count);
  };
}

const counter = outer();
counter(); // 1
counter(); // 2

outer() has already returned, but increment still has access to count. That's a closure.

How garbage collection actually works

JavaScript uses a strategy called mark-and-sweep. The engine periodically walks through all objects starting from known "roots" (like global variables and currently running functions), and marks everything it can reach. Anything left unmarked — unreachable — gets swept away and its memory freed.

The key idea: it's not about how old something is or how long it's been sitting there. It's purely about reachability.

Why closures keep variables alive

This is where it clicks. A closure holds a live reference to its outer scope. As long as the closure itself is reachable — assigned to a variable, attached to a DOM element, sitting in an event listener — the garbage collector can't touch anything inside that scope, even variables that function has long since "finished" using.

In the example above, count stays alive in memory for as long as counter exists, because increment still references it.

The gotcha: closures can keep more alive than you think

Here's the part that catches experienced developers off guard — a closure that uses one variable from a scope can keep the entire scope alive, including variables it never even touches.

function setup() {
  const bigData = new Array(1000000).fill('some data');
  const id = 42;

  return function handler() {
    console.log(id); // only uses `id`
  };
}

const handler = setup();

Even though handler only references id, bigData is part of the same closure scope. Depending on how the engine optimizes, bigData can remain in memory as long as handler does — a large array kept alive by a function that never even reads it.

A real-world leak: forgotten event listeners
This isn't just theoretical. It's a classic memory leak pattern:

function attachHandler() {
  const largeCache = loadHugeDataset();

  document.getElementById('btn').addEventListener('click', function () {
    console.log('clicked');
  });
}

The click handler is a closure. As long as the event listener is attached, the closure — and everything in its scope, including largeCache — stays reachable. If this element is removed from the DOM without removing the listener, you've got a detached DOM node silently holding onto memory.

Spotting this in DevTools

You don't have to guess — Chrome DevTools makes this visible:

  1. Open DevTools → Memory tab

  2. Take a heap snapshot

  3. Search for "Detached" — this surfaces DOM nodes that are no longer in the page but still retained in memory

  4. Expand the retainers to see what's holding onto them — often you'll find a closure sitting in the reference chain

This is the single most useful habit for catching leaks before they ship.

Practical fixes

  • Remove event listeners when they're no longer needed (removeEventListener, or cleanup functions in frameworks like React)

  • Avoid capturing large objects in a closure if the closure only needs a small piece of them — destructure what you need instead of holding the whole object

  • Null out references to large objects when you're done with them, especially in long-lived closures (timers, caches, subscriptions)

  • Be deliberate: ask "what does this closure actually need to hold onto?" rather than letting it capture everything in scope by default