Engrain by ByteRush

JS.02.04 Mid-level· 10 minCore — asked often

Closures and memory: how a small function can keep big data alive

A closure keeps its variables alive as long as it lives. See how forgotten listeners leak memory, a V8 detail that surprises people, and how to find leaks.

Comes up in: JavaScript, Web fundamentals

Learn#

Farhan’s dashboard gets slower every hour. Chrome’s Task Manager shows the tab growing from 80 MB to 900 MB. Every time a chart opens, it adds a resize listener. Charts close, listeners stay.

JavaScript frees memory with garbage collection: anything your program can no longer reach is cleaned up. A closure keeps its variables reachable for as long as the closure itself is reachable. That is called retention.

So a listener on a long-lived object — window, a global store, an event bus — keeps its closure alive, and the closure keeps alive every variable it uses. Memory that should be freed but is still reachable is a memory leak.

If that closure also references DOM nodes you removed, those “detached” nodes cannot be freed either.

A forgotten listener keeps everything aliveStep 1 of 5

event bus

    empty

    kept alive

      empty

      Call stack

      • openChart

      openChart() creates a big data array and a resize handler that uses it.

      Read the steps as text
      1. openChart() creates a big data array and a resize handler that uses it.
      2. The handler is added to a global event bus. The bus now holds the handler, and the handler holds the data.
      3. The user closes the chart. The chart's DOM is removed, but nobody removed the handler.
      4. Open and close it 20 times: 20 handlers, 100 MB that the garbage collector cannot free.
      5. With cleanup, close() removes the handler. Nothing reaches the data, so it can be collected.

      The fix is always the same: when the thing is gone, remove what points to it — removeEventListener, unsubscribe, clear the timer.

      Predict#

      Predict first

      A component adds window.addEventListener('scroll', onScroll) on mount and never removes it. onScroll uses a 2 MB array. What happens after the component unmounts?

      Interview angle#

      Say it like this — about 30 seconds

      “Closures keep the variables they use alive while the closure is reachable. Leaks happen when something long-lived — window, a global store, a timer, a cache — still holds a closure we no longer need, so its data and any detached DOM it references cannot be collected. The fix is cleanup: remove listeners, clear intervals, unsubscribe. To find leaks I take heap snapshots before and after repeating an action, and look at what grew and its retainers.”

      Stays on your device. Nothing is uploaded.
      Follow-up How do you find a memory leak in Chrome?
      Record a heap snapshot, repeat the action several times, take another, and compare. Look for growing object counts and “Detached” DOM nodes, then follow the retainers path to the culprit.
      Follow-up Does a closure keep the whole outer scope alive?
      In the spec it keeps the environment. In V8, closures from the same scope share one context, so a variable used by any of them can be kept alive by all of them.
      Follow-up How do frameworks help?
      React effects return a cleanup function; Angular has ngOnDestroy; AbortController signals can remove many listeners at once.

      Practice#

      On your own

      openChart subscribes to a bus but close() does nothing. Make close() remove exactly the handler it added, so the chart’s data is no longer reachable.

      Done when

      • Before close(), a “resize” event updates chart.width.
      • After close(), the bus holds no handler, and calling close() twice is safe.
      • Closing one chart does not remove another chart’s handler.
      Your solution
      Ctrl/⌘ + Enter

      Remember#

      Try to answer each card before you open it. Then add them to your reviews.

      When can the garbage collector free an object?

      When nothing your program can reach still references it.

      How do closures cause memory leaks?

      A long-lived object keeps a closure, and the closure keeps its variables alive.

      What is a detached DOM node?

      A node removed from the page but still referenced from JavaScript, so it cannot be freed.

      How do you confirm a leak in Chrome DevTools?

      Compare heap snapshots before and after repeating an action, and follow the retainers.

      Go deeper

      Four layers. Stop at the one your interview needs; each ends with a quick check.

      The core ideaEvery round

      Reachable means alive. A closure registered on something long-lived stays alive, and so does everything it uses. Clean up what you register.

      Check yourself: Which outlives a component: window or the component's closure?

      window. So a listener on window outlives the component unless removed.

      How it works underneathSenior rounds

      Collectors start from roots — globals, the call stack, live handles — and mark everything reachable from them; the rest is freed. Since ES2021, WeakRef lets you observe this without keeping an object alive Source: MDN: Memory management.

      Check yourself: What do garbage collectors start from when finding reachable objects?

      Roots: the global object, the current call stack, and similar.

      Edge cases and trapsWhere people slip

      This is V8 behavior, not the spec. Closures created in the same scope share one context object, so keepSmall keeps big alive because useBig captured it:

      import { setFlagsFromString } from "node:v8";
      import { runInNewContext } from "node:vm";
      setFlagsFromString("--expose-gc");
      const gc = runInNewContext("gc");
      const tick = () => new Promise((r) => setTimeout(r, 0));
      let keepSmall;
      function setup() {
        const big = { data: new Array(1e6).fill(0) };
        const small = "ok";
        const useBig = () => big.data.length;
        keepSmall = () => small;
        useBig();
        return new WeakRef(big);
      }
      const ref = setup();
      await tick(); gc();
      console.log("big while keepSmall lives:", ref.deref() ? "alive" : "collected");
      keepSmall = null;
      await tick(); gc();
      console.log("big after keepSmall = null:", ref.deref() ? "alive" : "collected");
      This example uses Node.js, so it runs in our checks rather than in your browser.

      Output (verified)

      big while keepSmall lives: alive
      big after keepSmall = null: collected
      Check yourself: In V8, why can a closure that only uses `small` keep `big` alive?

      Closures from the same scope share one context; another closure captured big into it.

      In productionStaff-level answers

      Find leaks with Memory panel heap snapshots: take one, repeat the action five times, take another, use the Comparison view, and search “Detached” Source: Chrome DevTools: Record heap snapshots. Prevent them with effect cleanups, AbortController signals for listeners, and WeakMap for caches keyed by objects (JS.06.02).

      Check yourself: Which DevTools filter shows nodes removed from the page but still in memory?

      Searching the heap snapshot for "Detached".

      Finished the five steps? Mark it, and its cards join your reviews.

      Last checked 2026-10-31. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.

      Interview questions this answers

      • How can closures cause memory leaks?