Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B13
13 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB13 · Part 1 · Beginner

Closures

The function that walked out with its birthplace still attached.

8 min read12 sections2 exercises
13 of 41

In this chapter

  • Nothing gets turned on
  • Watch it happen in space
  • Now step through it line by line
  • Why the variable survives at all
  • A live link, not a snapshot

and 7 more

about 8 min
12On this page
  1. Nothing gets turned on
  2. Watch it happen in space
  3. Now step through it line by line
  4. Why the variable survives at all
  5. A live link, not a snapshot
  6. One call, one closure
  7. Five real jobs closures do
  8. The loop trap, from the closure side
  9. See what the callback captured
  10. Closures and memory — the part that bites in production
  11. Seeing one for yourself
  12. The traps, collected

Nothing gets turned on

A closure is not syntax you write. It is what you already have. Every function, the moment it is created, keeps a link to the environment it was created in — that is just the outer reference from the execution context chapter, sitting there doing nothing interesting.

It only becomes visible when the function outlives the call that made it. Then the link is the only thing keeping that environment alive, and suddenly everyone has a name for it.

function makeCounter() {
  let count = 0;                  // a local. Should die when makeCounter returns.
  return () => ++count;           // but this little function still points at it
}

const next = makeCounter();
next();   // 1
next();   // 2  — count outlived the call that created it

Watch it happen in space

The flat version of this picture is what causes the confusion — it looks like count gets copied into the function. It does not. Drag the scene, or use the sliders, and step through: the call frame goes away, the environment stays, because something in front of it is still holding on.

A closure, in three dimensions
Global environment
makeCounter = fn
makeCounter() environment
count = 0
still reachable
a second environment
count = 0
next() — the returned function
code + a link back
drag to rotate

Now step through it line by line

The 3D scene shows the idea. This player runs the same program the way the engine does, one line at a time. Press Next until counter() returns: its frame turns dashed because the call has ended, yet it is not thrown away.

▶Play it

  • Nothing has run yet. Functions written with function already exist in memory: JavaScript sets them up before the first line runs.

Next line to run: const next = counter(); (line 6)

Code

  1. function counter() {
  2. let n = 0;
  3. return () => ++n;
  4. }
  5. nextconst next = counter();
  6. next();
  7. next();

Printed output

nothing printed yet

What the engine holds in memory

Frames

Global
counter

Objects

ƒ counter()function

defined at the top level

  • a variable points to an object
  • a function remembers the frame it was made in
  • dashed frame: its call ended, but something still needs it

Why the variable survives at all

In the execution context chapter you learned that a call gets an execution context, and that the context leaves the stack when the call finishes. That is still true — of the stack frame. The frame is bookkeeping: where to return to, what is running. It pops.

The environment — the box holding the actual variables — lives somewhere else, on the heap, and it follows one rule only:

Rule JavaScript keeps anything that is still reachable. A closure is a reference, and a reference is reachability. There is no special "closure keep-alive" mechanism — the ordinary garbage collector simply cannot collect a box that something still points at.

Which is why closures are not free, and why the memory section below is not an afterthought.

A live link, not a snapshot

The most common wrong mental model is that the inner function copies the values it needs. Two functions made in the same call share one environment, and that settles it:

function makeBox() {
  let value = "first";
  return {
    read: () => value,
    write: (v) => { value = v; },
  };
}

const box = makeBox();
console.log(box.read());     // "first"
box.write("second");
console.log(box.read());     // a copy would still say "first"

"second". read and write are two different functions closing over the same box, so a change made through one is visible through the other. If closures copied values, that would be impossible.

Here is the same idea with a bank balance. Both methods point at one frame, and nothing outside can reach balance except through them.

▶Play it

  • Nothing has run yet. Functions written with function already exist in memory: JavaScript sets them up before the first line runs.

Next line to run: const bank = makeBank(); (line 14)

Code

  1. function makeBank() {
  2. let balance = 100;
  3. return {
  4. deposit(x) {
  5. balance += x;
  6. return balance;
  7. },
  8. peek() {
  9. return balance;
  10. },
  11. };
  12. }
  13. nextconst bank = makeBank();
  14. bank.deposit(50);
  15. console.log(bank.peek());

Printed output

nothing printed yet

What the engine holds in memory

Frames

Global
makeBank

Objects

ƒ makeBank()function

defined at the top level

  • a variable points to an object
  • a function remembers the frame it was made in
  • dashed frame: its call ended, but something still needs it

One call, one closure

function makeCounter() {
  let count = 0;
  return () => ++count;
}

const a = makeCounter();
const b = makeCounter();

a(); a(); a();
b();

console.log("a:", a(), " b:", b());   // what happens?

a: 4 b: 2. Two calls, two environments, two completely separate counts. The closure belongs to the call, never to the function you wrote — which is exactly what the last step of the 3D scene was showing.

Dry run: what each call actually incremented

CallWhich environmentcount beforecount after
a() — 1sta's (from the first makeCounter())01
a() — 2nda's12
a() — 3rda's23
b() — 1stb's (from the second makeCounter())01
a() in the final loga's34
b() in the final logb's12

a and b never touch the same count — each call to makeCounter() built a fresh environment, so there were always two separate boxes, not one shared between them.

Step through the same program and compare the two kept frames: each call to makeCounter() left behind its own box.

▶Play it

  • Nothing has run yet. Functions written with function already exist in memory: JavaScript sets them up before the first line runs.

Next line to run: const a = makeCounter(); (line 9)

Code

  1. function makeCounter() {
  2. let n = 0;
  3. return function inc() {
  4. n += 1;
  5. return n;
  6. };
  7. }
  8. nextconst a = makeCounter();
  9. const b = makeCounter();
  10. a();
  11. a();
  12. b();
  13. console.log(a(), b());

Printed output

nothing printed yet

What the engine holds in memory

Frames

Global
makeCounter

Objects

ƒ makeCounter()function

defined at the top level

  • a variable points to an object
  • a function remembers the frame it was made in
  • dashed frame: its call ended, but something still needs it

Five real jobs closures do

Interviews rarely ask "what is a closure". They ask you to build one of these.

1. Factories — functions that build functions

function multiplierOf(factor) {
  return (n) => n * factor;
}

const double = multiplierOf(2);
const triple = multiplierOf(3);
double(5);   // 10 — "factor" is remembered inside double, permanently

2. Privacy — state no outside code can reach

function createAccount(startingBalance) {
  let balance = startingBalance;    // truly private — no this.balance to poke at
  return {
    deposit: (n) => (balance += n),
    getBalance: () => balance,
  };
}

const acct = createAccount(100);
acct.deposit(50);
acct.getBalance();     // 150
acct.balance;          // undefined — there is no way in

This is the oldest privacy mechanism in JavaScript, and still the simplest. Classes got a real one in ES2022 — #private fields — but a closure needs no syntax at all.

3. Memoize — a cache only the function can see

function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

let calls = 0;
const slowSquare = memoize((n) => { calls++; return n * n; });
slowSquare(5);
slowSquare(5);
slowSquare(5);
console.log("real calls:", calls);   // what happens?

1 — the underlying function runs once per distinct set of arguments. cache is closed over by the returned function and nothing else, so no outside code can reach or corrupt it.

4. Once — work that happens a single time

function once(fn) {
  let called = false, result;
  return function (...args) {
    if (!called) {
      called = true;
      result = fn.apply(this, args);
    }
    return result;
  };
}

let inits = 0;
const init = once(() => { inits++; return "ready"; });
console.log(init(), init(), init());
console.log("actual inits:", inits);   // what happens?

ready ready ready, then 1. Every call after the first returns the cached result without re-running fn — the standard shape behind "run this setup exactly once, however many times it is asked for."

5. Debounce and throttle — a timer nobody outside can see

function debounce(fn, delay) {
  let timer;                        // private to the returned function
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

function throttle(fn, interval) {
  let ready = true;
  return function (...args) {
    if (!ready) return;
    ready = false;
    fn.apply(this, args);
    setTimeout(() => { ready = true; }, interval);
  };
}
Rule Debounce waits for a pause and runs once at the end — a search box waiting until they stop typing. Throttle runs immediately, then enforces a cooldown — a scroll handler firing at most once every N ms, the whole time they scroll.

The loop trap, from the closure side

The var, let and const chapter showed this as a question about how many bindings a loop creates. From here it is the same fact, viewed from the other end: all three callbacks closed over the same environment, so they all read the same box — and by the time any of them ran, the loop had finished writing to it.

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);   // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j), 0);   // 0 1 2

One shared box versus a fresh box per iteration. Nothing about the callbacks changed.

Step through both versions. With var there is one i in the global scope. With let every pass gets its own scope, and each callback keeps one of them alive.

▶Play it

  • Nothing has run yet.

Next line to run: var fns = []; (line 1)

Code

  1. nextvar fns = [];
  2. for (var i = 0; i < 3; i++) {
  3. fns.push(() => i);
  4. }
  5. console.log(fns[0](), fns[1](), fns[2]());

Printed output

nothing printed yet

What the engine holds in memory

Frames

Global
fns
undefined
i
undefined

Objects

no objects yet

  • a variable points to an object
  • a function remembers the frame it was made in
  • dashed frame: its call ended, but something still needs it

▶Play it

  • Nothing has run yet.

Next line to run: const fns = []; (line 1)

Code

  1. nextconst fns = [];
  2. for (let i = 0; i < 3; i++) {
  3. fns.push(() => i);
  4. }
  5. console.log(fns[0](), fns[1](), fns[2]());

Printed output

nothing printed yet

What the engine holds in memory

Frames

Global

no variables yet

Objects

no objects yet

  • a variable points to an object
  • a function remembers the frame it was made in
  • dashed frame: its call ended, but something still needs it

See what the callback captured

The same loop twice, one keyword apart. Watch how many bindings each version creates — that is the whole difference.

var vs let in a loop — what the callback actually captured
Bindings in scope
Queued callbacks

Closures and memory — the part that bites in production

A closure is a reference, and a reference stops collection. Almost every JavaScript memory leak is a closure that outlived its usefulness while still holding something large.

// The listener never gets removed, so the closure lives forever —
// and it is holding a reference to a huge object it barely uses.
function attach(node, hugeDataset) {
  node.addEventListener("click", () => {
    console.log("clicked", hugeDataset.length);   // keeps hugeDataset alive
  });
}

// Same shape, worse: an interval no one clears keeps its closure,
// its closure keeps the node, and the node may already be off the page.
setInterval(() => update(detachedNode), 1000);
  • Remove listeners when the thing they belong to goes away, or use an AbortController signal to drop them all at once.
  • Clear intervals and timeouts. An uncleared interval is an immortal closure.
  • Do not close over more than you need. Pull out the one field you actually use rather than capturing the whole object.

Engines are smarter than the naive model: V8 captures only the variables a closure actually mentions, not the entire scope. But the ones it does capture are held completely — capture one field of a 10MB object and you keep the field, capture the object and you keep all 10MB. Engine & memory has the heap snapshots that let you see which.

Seeing one for yourself

This is not a metaphor you have to take on faith. Put a debugger statement inside a returned function, run it, and open the Scope panel in DevTools: alongside Local and Global there will be an entry labelled Closure (makeCounter), with the retained variables listed inside it — the middle plane of the 3D scene, printed as a tree.

The traps, collected

  • Expecting a snapshot. The closure reads the variable when it runs, not when it was created.
  • Sharing an environment by accident. Two functions from the same call share state — sometimes what you want, sometimes a bug.
  • Assuming a new closure per loop iteration. Only let and const give you that.
  • Leaking through listeners and intervals. The closure cannot be collected while the subscription is alive.
  • Thinking it is rare. Every callback, every event handler, every module is a closure. It is the default, not the exception.
Say it like this → "A function keeps a link to the environment it was created in. When the function outlives the call, that environment cannot be collected — so the variable is still there, still live, and still shared with anything else created in the same call."
Interview answer, one sentence

"A closure is a function plus a live link to the scope where it was created, so it keeps those variables alive after the outer function returns; it captures the variable, not a copy of its value, which is why counters and private state work and why a var in a loop gives every callback the same value."

Before you move on
  • Explain why count in makeCounter survives after the function returns, in terms of what the garbage collector can and can't touch.
  • Say why two calls to the same factory function produce two independent closures, not one shared one.
  • Build a private counter or cache using a closure, with no property anyone outside can reach directly.
  • Explain why the var loop prints 3 3 3 and the let loop prints 0 1 2, in terms of how many bindings each one creates.
  • Name two ways a closure can leak memory in production, and the fix for each.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsA counter nobody can cheatbeginner
  • 8 testsonce() — run it a single timeintermediate
All 35 for this level →
B13

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B12Previous · B12The this keyword
  2. B13you are here
  3. Next · B14DOM & eventsThe DOM is a live tree of objects — and JS can poke every branch of it.7 min read

01On this page

12 sections · 0%

  1. 01Nothing gets turned on
  2. 02Watch it happen in space
  3. 03Now step through it line by line
  4. 04Why the variable survives at all
  5. 05A live link, not a snapshot
  6. 06One call, one closure
  7. 07Five real jobs closures do
  8. 08The loop trap, from the closure side
  9. 09See what the callback captured
  10. 10Closures and memory — the part that bites in production
  11. 11Seeing one for yourself
  12. 12The traps, collected

[] chapters · t top