Scope and closures
Most sections open with a short snippet and a collapsed Output.
Work out what it does before you open it. That takes ten seconds and it is the whole point — you learn from the ones you get wrong, not from the ones you get right.
What exists before your code runs
1.1 Three declarations, three behaviours
console.log(a);
console.log(b);
console.log(c());
var a = 1;
let b = 2;
function c() {
return 3;
}
Output
undefined
ReferenceError: Cannot access 'b' before initialization
c() would have returned 3 had line 2 not thrown. Comment out the middle line and check.
Three declarations, three completely different behaviours, all from lines that have not run yet.
You were probably taught that declarations "move to the top of their scope". That is a beginner's explanation and it falls apart in section 1.2. Here is the accurate version:
Before any code in a scope runs, the engine reads the whole thing and creates every name declared in it. Then it runs the code.
What differs is whether the name has a value yet.
| You wrote | Before any code runs, the name is… |
|---|---|
function c() { return 3; } | created and already holds the whole function, body included |
var a | created and holds undefined |
let b / const / class | created but has no value — reading it throws |
Nothing moved. All three names existed before line one. They started in different states.
That is why a is undefined rather than an error: the name exists, it just has not been assigned yet. And it is why c() works before its declaration: functions arrive complete.
1.2 The temporal dead zone
console.log(typeof notDeclaredAnywhere);
console.log(typeof z);
let z = 1;
Output
undefined
ReferenceError: Cannot access 'z' before initialization
typeof on a name that has never been declared gives "undefined". typeof on a let before its line throws.
That is the row three state from the table above, and it has a name: a variable is in the temporal dead zone between being created and being given a value.
It is not a region of code. It is a state the variable is in. A reserved seat with your name on it that you are not yet allowed to sit in — the seat exists, it is definitely yours, and it is not ready.
Why does this exist? Why not give let the value undefined, like var?
Because of const. If a const were readable before its initialiser ran, you could watch it change from undefined to its real value, and "constant" would be a lie. Once const needs a no-value state, giving let the same behaviour costs nothing and keeps the two consistent.
As for typeof: it is specified not to throw for a name that does not exist anywhere, and that guarantee has been used for feature detection since the 1990s. But z does exist. It was created before the code ran and simply has no value, so the guarantee does not cover it.
And "temporal" is doing real work — this is about when, not where:
function f() {
return z;
} // defining this is fine
f(); // ReferenceError — called too early
let z = 1;
f(); // 1 — same function, now fine
Scope
2.1 Nested scopes and the lookup rule
A scope is a region of code where a set of names is visible.
const a = 1;
function outer() {
const b = 2;
function inner() {
const c = 3;
return a + b + c;
}
return inner();
}
While inner runs there are three scopes:
Global scope
a
outer
│
└── outer's scope
b
inner
│
└── inner's scope
c
Name lookup follows one rule, repeated:
Is the name in the current scope? Use it. If not, look in the enclosing scope. Repeat outward. If you run out,
ReferenceError.
That path from inner to global is the scope chain. c is found immediately, b after one step, a after two.
The part that matters for the rest of this page:
Which scope encloses which is decided by where the code is written, not by who calls what at runtime.
inner can see b because it is written inside outer — not because outer called it.
(You will see this called lexical scope in documentation. That is all the term means.)
Some languages resolve a name by walking the call stack — asking each caller in turn, at runtime, until one of them has it. That is dynamic scope, and JavaScript variables never work that way.
const name = "global";
function read() {
return name;
}
function run() {
const name = "Ada";
return read();
}
run(); // 'global' ← not 'Ada'
read ignores run entirely. It was written next to the global name, so that is the one it gets, no matter who calls it or what they have in scope.
Four things that look like dynamic scope
try/catch is the closest mechanism in the language, and it is control flow rather than scope. A throw finds the nearest handler on the call stack, so risky() is caught by whichever function called it — move the call and a different handler runs, with no lexical relationship involved. But what is being found is a handler, not a name. Errors travel up the call stack; names do not.
this is dynamically determined, but not dynamically scoped. It changes with every call, but there is no chain walk:
"use strict";
function inner() {
return this?.name;
}
function outer() {
return inner();
} // plain call
outer.call({ name: "Ada" }); // undefined, not 'Ada'
Under real dynamic scope, inner would find outer's value by walking up. It does not — a plain call resets this. So this is better described as an implicit parameter set by the call site. Week 3.
React's useContext is a possible analogy. It walks the runtime chain, up the render tree to the nearest Provider, and the render tree is built by who-rendered-whom. Move a component and the value changes. It is opt-in and keyed by a context object rather than resolving bare names, so it is an analogy rather than the same thing — but it is a good one, and people describe Context in exactly these terms.
Node's AsyncLocalStorage in Node is a similar mechanism. getStore() returns whatever the enclosing run() established, following the async call chain rather than lexical position. It exists precisely because people want dynamic scoping and the language does not offer it for variables. We use it in week 9.
What creates a scope:
| Construct | Creates a scope for |
|---|---|
| a function | var, let, const, parameters, everything |
a block { } | let, const, class only |
a catch (e) clause | the caught binding |
| a module | its top-level declarations |
var ignores blocks entirely and belongs to the nearest enclosing function. That one difference causes most of the confusion in this area, and it is why let exists.
2.2 The global scope has two halves
var x = 1;
let y = 2;
console.log(globalThis.x, globalThis.y);
Output
1 undefined
At the top level, var and let end up in different places. var and function declarations become properties of the global object. let, const and class go into a separate global scope with no object behind it at all.
Both are global in the sense that everything can see them. Only one is reachable through globalThis.
Two consequences:
Top-level var cannot be deleted. It creates a non-configurable property.
The non-object half is checked first, so a top-level let shadows a same-named property on the global object:
globalThis.status = "from host";
let status = "from script";
console.log(status); // 'from script'
globalThis.status = "changed";
console.log(status); // still 'from script'
Why build it this way? Making every let a property of the global object would create a new collision surface with the hundreds of browser APIs already living there. ES6 took the chance not to repeat a known mistake.
Modules avoid the question entirely — their top-level declarations never touch the global object.
Closures
3.1 Two counters
function counter() {
let n = 0;
return () => ++n;
}
const c1 = counter();
const c2 = counter();
c1();
c1();
console.log(c1(), c2());
Output
3 1
counter has finished. Normally its local variables would be gone. Yet n is still there, still counting — and c1 and c2 have different ones.
Now the same idea with two functions instead of one:
function makeBoth() {
let n = 0;
return { inc: () => ++n, get: () => n };
}
const { inc, get } = makeBoth();
inc();
inc();
console.log(get());
Output
2
Here the two functions share a counter.
Those two results together give you the rule, and the rest of this page rests on it:
A function remembers the scope it was created in.
One call to counter() creates one scope. Every function created during that call shares it. A different call creates a different scope.
c1 and c2 came from different calls — different scopes, different n. inc and get came from the same call — same scope, same n.
That pairing of a function with the scope it captured is what closure means. Not a special kind of function, and nothing you opt into — every function in the language has one.
3.2 Closures capture variables, not values
let x = 1;
const show = () => console.log(x);
x = 2;
show();
Output
2
The function did not photograph x when it was created. It kept a reference to the variable itself, so it sees the later change.
Hold on to that for the next section.
3.3 The loop
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i));
Output
3 3 3
0 1 2
With var: var belongs to the enclosing function scope, so there is one i. All three arrow functions capture that one variable — section 3.2 — and by the time the timers run, the loop has finished and i is 3.
enclosing function scope
i: 3 ◄── captured by all three functions
With let: the for statement creates a fresh variable for each pass, initialised by copying the value from the previous one. Each arrow function is created during a different pass, so each captures a different variable.
pass 1 pass 2 pass 3
i: 0 i: 1 i: 2
▲ ▲ ▲
function A function B function C
The usual explanation, "let is block-scoped", is true and explains nothing, because the loop body is a block in both versions. The specific behaviour is that the loop head makes a new variable per pass and copies the value forward.
Section 5.1 shows you how to confirm that in the specification rather than taking my word for it.
3.4 How this was done before let
Historical aside — you will meet this in old code
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j));
})(i);
}
Calling a function creates a scope. The parameter j is a fresh variable per call, initialised by copying the current i. That is per-pass binding done by hand, ten years before let, using the only scope-creating construct the language had.
The same trick was the module system before there was one. Every library that begins (function (global) { exists because a function call was the only way to get a private scope. Week 6 covers what replaced it.
What closures cost
4.1 The consequence
You now know a function keeps its defining scope alive. Turn that around:
As long as a function object exists, nothing in the scope it captured can be freed.
The cost of a closure is not the function object. It is everything the function can see.
In counter(), that is exactly what you wanted — n surviving is the feature. The problem is that the same mechanism keeps things alive when you did not want it.
4.2 What a "leak" means here
JavaScript's garbage collector does not count references. It asks one question:
Starting from a set of roots, what can I reach by following references?
Reachable is live. Everything else is collected, whatever your intentions.
Roots include the global object, the current call stack, the scopes of any function still reachable, DOM nodes in the document, and pending timers and promise handlers.
In a language like C you leak by forgetting to free. Here you cannot forget, so a leak means something different:
A leak is unintended reachability. Something is still pointing at data you are finished with.
The collector is working perfectly. Your object graph is wrong.
That changes what you look for. The question is never "what did I forget to clean up". It is always "what is still pointing at this" — which is exactly what the tooling in 4.4 answers.
4.3 Five ways this happens
1. A listener that is never removed
The most common one, and it does not need a class:
function attachTooltip(element, text) {
const content = buildTooltip(text); // could be large
document.addEventListener("scroll", () => {
reposition(element, content);
});
}
Call this for fifty elements and you have added fifty listeners to document. document lives as long as the page, so every element and every content stays alive — including for elements that were removed from the DOM long ago.
The fix people try first, which does not work:
element.addEventListener("click", () => handle());
element.removeEventListener("click", () => handle()); // removes nothing
Those are two different function objects. removeEventListener compares by identity, so it finds no match and silently does nothing. No error.
The fix that works:
const onClick = () => handle();
element.addEventListener("click", onClick);
element.removeEventListener("click", onClick); // same reference
The fix that scales, and the one worth adopting as a habit:
function attachTooltip(element, text, { signal }) {
const content = buildTooltip(text);
document.addEventListener(
"scroll",
() => {
reposition(element, content);
},
{ signal }
);
}
const controller = new AbortController();
attachTooltip(a, "first", { signal: controller.signal });
attachTooltip(b, "second", { signal: controller.signal });
// teardown — removes both listeners, and any others sharing this signal
controller.abort();
The signal option means you never have to keep the function reference around. One abort() cleans up everything registered with that signal. A lot of people do not know this exists.
2. A timer that is never cleared
setInterval(() => update(bigObject), 1000);
The interval holds its callback, which holds its scope, which holds bigObject, forever. setInterval with no matching clearInterval is the most reliable leak in the language.
const id = setInterval(() => update(bigObject), 1000);
// teardown
clearInterval(id);
3. Detached DOM
const opened = [];
function open(item) {
const panel = document.createElement("div");
panel.append(render(item));
opened.push(panel); // ← the leak
document.body.append(panel);
}
function close(panel) {
panel.remove(); // out of the document, still in `opened`
}
A DOM node references its parent, its children and its listeners, so one retained node can hold a whole subtree you never touched directly.
4. A cache with no eviction
const memo = new Map();
function expensive(key) {
if (!memo.has(key)) memo.set(key, compute(key));
return memo.get(key);
}
Correct, useful, and it grows forever. A cache without an eviction policy is a leak with good intentions. Section 4.5 has one fix; a size limit is the other.
5. A promise that never settles
A pending promise holds its handlers, and those hold their scopes.
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Timeout")), ms);
});
return Promise.race([promise, timeout]);
}
This looks fine and leaks twice over. If promise wins the race, the timer is still pending, still holding its reject callback. And if the timeout wins, promise carries on running with its handlers attached — Promise.race gives you an early answer, it does not stop the loser.
Clearing the timer:
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("Timeout")), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
Actually stopping the work, which is better where the API supports it:
const data = await fetch(url, { signal: AbortSignal.timeout(5000) });
Now nothing is left pending: the request is cancelled, its handlers are released, and there is no stray timer.
6. A job that keeps running after you stopped waiting
The timer case above has an obvious fix. This one does not.
async function analyseRows(rows) {
let total = 0;
for (let i = 0; i < rows.length; i++) {
total += score(rows[i]);
if (i % 1000 === 0) await new Promise((r) => setTimeout(r)); // let the page breathe
}
return total;
}
// somewhere in a component
const result = await Promise.race([analyseRows(rows), userCancelled]);
The user clicks cancel, userCancelled wins the race, and your code moves on. analyseRows does not. It is still looping, still holding rows and total and its entire scope, and it will keep taking the main thread until it reaches the end of a list nobody is waiting for.
There is no timer to clear here. There is no handle to release. The only thing that can stop that loop is the loop.
Because none of it is garbage.
When analyseRows reaches an await, its state — rows, total, i, the whole scope — is held alive by the thing that will resume it: a job waiting in the event loop's queue. That queue is a GC root, so there is an unbroken chain of references from a root to your data.
The collector is behaving perfectly. This is not memory that failed to be freed. It is live work that is still scheduled.
Which is what makes this different from the five patterns above. Those quietly hold memory. This one holds memory and keeps taking the main thread.
score() runs on the same single thread as everything else — week 4's subject. Every millisecond the loop spends is a millisecond the browser cannot use to paint a frame or handle a click. The page will feel sluggish for as long as the loop runs, which is until the end of a list nobody is waiting for.
The await every thousand rows is what stops this from being a complete freeze: it hands the thread back so the browser gets a turn between chunks.
Notice that the same await is doing two separate jobs — it keeps the page responsive, and it creates the moment at which the signal can be checked. Those turn out to be the same thing.
async function analyseRows(rows, { signal } = {}) {
let total = 0;
for (let i = 0; i < rows.length; i++) {
total += score(rows[i]);
if (i % 1000 === 0) {
signal?.throwIfAborted(); // ← the actual stop
await new Promise((r) => setTimeout(r));
}
}
return total;
}
const controller = new AbortController();
cancelButton.addEventListener("click", () => controller.abort());
try {
const total = await analyseRows(rows, { signal: controller.signal });
show(total);
} catch (err) {
if (err.name !== "AbortError") throw err;
}
The loop now checks the flag every thousand rows and throws when it is set. The function unwinds, its scope is released, and the main thread is free.
It is a flag plus an event. Nothing gets interrupted. The running code has to look.
fetch feels different only because the browser implements that check for you and closes the connection — and note that even then, a server already processing your request carries on regardless. Aborting is local.
A loop you wrote has to check for itself.
And a purely synchronous loop cannot be cancelled at all — but not for the reason you might expect. It is not that the loop has nowhere to check. It is that the abort can never happen in the first place.
JavaScript runs one thing at a time. While your synchronous loop is running, nothing else does, including the click handler that would call controller.abort(). The user's click sits in a queue waiting for your loop to finish.
So even a loop that checked signal.aborted on every single iteration would read false every time, because the code that would set it to true cannot run until the loop is over. By the time the abort is processed, there is nothing left to abort.
Cancellable work has to be interruptible work. That is what the await buys you, and it is week 4 arriving early.
4.4 Finding a leak
Here is a page that leaks. Save it and open it in Chrome or Edge.
<!doctype html>
<meta charset="utf-8" />
<button id="open">Open panel</button>
<button id="close">Close all</button>
<div id="host"></div>
<script>
const opened = [];
document.getElementById("open").addEventListener("click", () => {
const panel = document.createElement("div");
panel.className = "panel";
panel.textContent = "x".repeat(200000);
opened.push(panel);
document.getElementById("host").append(panel);
});
document.getElementById("close").addEventListener("click", () => {
document.querySelectorAll(".panel").forEach((p) => p.remove());
});
</script>
Clicking Open panel then Close all looks like it cleans up. The panels are out of the document. They are still in opened.
Now find that without knowing it in advance.
- Open DevTools → Memory.
- Click the collect garbage icon (the bin) for a clean baseline.
- Take a heap snapshot. Call it snapshot 1.
- On the page, click Open panel ten times, then Close all.
- Collect garbage again, then take snapshot 2.
- At the top of snapshot 2, change the dropdown from Summary to Comparison, with snapshot 1 as the baseline.
- Sort by Delta.
What you are looking for: a constructor whose count went up by about ten and did not come back down. Here it is Detached HTMLDivElement, and you can find it faster by typing Detached into the class filter box.
- Expand it and click one instance.
- Look at the Retainers pane at the bottom.
That pane is the answer. It shows the chain of references holding the object alive, and you read it bottom-up as a sentence:
this detached div is in an
Array, which is the variableopened, which is in the script's scope, which is reachable fromWindow.
That sentence names the line you have to change.
Two numbers you will see, and what they mean.
- Shallow size is how big the object itself is. For a closure this is a few dozen bytes and tells you nothing.
- Retained size is how much would be freed if this object vanished — everything that would become unreachable.
Sort by retained size. A tiny closure can have a retained size of megabytes, because it is the only thing holding a large scope.
One thing about retained size surprises people. Imagine a village reachable by one bridge: destroy the bridge and the village is cut off. Now imagine a village with two bridges: destroying either cuts off nothing. Heap objects work the same way, so an object referenced from two places shows a small retained size against each referrer even if it is huge.
Objects you console.log are kept alive by DevTools so you can expand them later, and they show up in snapshots.
Clear the console before taking one.
4.5 WeakMap
If retention is the problem, weak references are one tool.
A WeakMap holds its keys weakly. If nothing else references a key object, the entry disappears and both key and value can be collected.
const metadata = new WeakMap();
metadata.set(element, { clicks: 0 });
// element removed from the DOM and forgotten → the entry goes with it
The natural use is attaching data to objects you do not own, and it is the right fix for the cache in 4.3.4 when the keys are objects.
What you give up:
- Keys must be objects.
- No iteration, no
size. - No way to observe when an entry disappears.
Those restrictions all follow from one principle: garbage collection must not be observable. If you could iterate a WeakMap, its contents would depend on whether the collector had happened to run, and the same program would behave differently under different memory pressure. The language refuses to allow that.
4.6 Why a closure can hold something it never mentions
One implementation detail is worth knowing, because it produces a leak you will not find by reading the code.
V8 does not put every local variable on the heap. While parsing, it works out which variables are used by inner functions and only those are moved to a heap object shared by every function created in that scope.
Shared is the operative word:
function setup() {
const huge = new Array(1e6).fill("x");
const count = huge.length;
const useHuge = () => huge[0];
const useCount = () => count;
document.addEventListener("click", useCount);
return useCount;
}
useCount never mentions huge. But useHuge does, so huge is on the heap, in the storage both functions share. Keeping useCount alive keeps huge alive.
The fix is to keep the large value out of that shared scope:
function setup() {
const count = (() => {
const huge = new Array(1e6).fill("x");
return huge.length;
})();
const useCount = () => count;
document.addEventListener("click", useCount);
return useCount;
}
function outer() {
const used = "I am referenced";
const unused = "I am not";
return function inner() {
debugger;
return used;
};
}
outer()();
Run it with DevTools open. When it pauses, look at the Scope pane on the right. Under Closure, used is there and unused is not, because V8 never put it on the heap.
Add a reference to unused inside inner and run it again. Now it appears.
You should now be able to
- Predict what a name holds on a line above its own declaration — a complete function,
undefined, or aReferenceError— and explain why the third is a feature rather than a restriction. - Trace a name through nested scopes to the declaration it resolves to, and say which scope a lookup stops at when an inner declaration shadows an outer one.
- Explain why
var xat the top level appears onglobalThisandlet xdoes not. - Given code that returns functions out of a call, say which of them share a variable and which get their own, and justify the answer from where each was written.
- Explain both loop results from
varandletwithout running them, using capture-by-variable rather than capture-by-value. - Say what a function keeps alive, recognise the shapes a leak takes — an uncleaned listener, an uncleared timer, detached DOM, an unbounded cache, a promise that never settles, a job still running after nobody is waiting — and name what is still pointing at the memory in each.
- Read a heap snapshot: distinguish shallow from retained size, sort by the one that matters, and decide when a
WeakMapis the right fix and what it costs you.
Reading the specification
Here's how you read the specification to verify the behaviour of closures and variable scoping.
The authority is ECMA-262, the language specification, at tc39.es/ecma262. It is more readable than its reputation suggests, and it is searchable.
Clause numbers change between yearly editions, so a numbered reference from a blog post is usually stale. Search for the name of the operation instead.
| You want to know | Search for |
|---|---|
| what a scope really is | Environment Records |
| why hoisting works | FunctionDeclarationInstantiation |
why the let loop differs | CreatePerIterationEnvironment |
| why the TDZ throws | GetBindingValue |
| how a name is looked up | GetIdentifierReference |
5.1 A worked lookup: the loop
In 3.3 it was said that for head makes a new variable each pass and copies the value forward. Search for CreatePerIterationEnvironment and you find roughly this:
- If there are no per-iteration bindings, return. (the
varcase — it exits here)- Let
lastIterationEnvbe the current environment.- Let
outerbelastIterationEnv's outer environment.- Create a new environment whose outer is
outer.- For each per-iteration name: create the binding, read the value from
lastIterationEnv, and initialise the new one with it.- Make the new environment current.
Step 4 creates the new scope. Step 5 is the copy forward. Step 1 is why var behaves differently — it exits immediately, so there is only ever one variable.
Two details you would not have guessed:
The per-pass scopes are siblings, not nested. Step 3 takes the outer environment, not the previous pass. If they nested, a thousand-iteration loop would build a thousand-deep scope chain and name lookup would get slower every pass.
It runs after each body, before the increment. So i++ happens in the newly created scope, and its result is what the next pass sees.
5.2 A second lookup, default parameters
Predict this one:
function f(a, b = () => a) {
var a = 99;
return b();
}
console.log(f(1));
Output
1
Not 99.
If a function has any parameter expression — a default value, a destructuring pattern, or a rest parameter — the engine creates two scopes: one for the parameters and one for the body.
b's default closed over the parameter a. The var a created a different variable in the body scope. Two variables named a, and b reads the first.
Remove the default and you get 99, because then there is only one scope.
Where that comes from
Search for FunctionDeclarationInstantiation and find the steps dealing with parameter expressions. Numbering moves between editions, so treat these as landmarks rather than citations.
An early step computes hasParameterExpressions — true if the parameter list contains any expression at all: a default value, a destructuring pattern, a rest element.
If it is false, the specification says outright that only a single Environment Record is needed for the parameters and the top-level vars. A var whose name matches a parameter is skipped, because that binding already exists. var a is the parameter.
If it is true, a second environment is created for the body, with the parameter environment as its outer:
- Let
varEnvbe a new declarative environment whose outer is the parameter environment.- For each
varnamen:
- create a binding for
ninvarEnv;- if
nis also a parameter, initialise it withenv.GetBindingValue(n)— the parameter's current value;- otherwise initialise it to
undefined.
The note printed beside that step says it plainly: a var with the same name as a formal parameter initially has the same value as the corresponding initialised parameter. Same value, different binding.
So for f(1):
- There is a default, so
hasParameterExpressionsis true, so there are two scopes. - The parameter scope gets
a = 1andb. b's default is evaluated in the parameter scope, so the arrow captures that one.- The body scope gets its own
a, initialised by copying1across. var a = 99writes to the body'sa.b()reads the parameter scope'sa, still1.
And why does the split exist at all? A note further up gives the reason: closures created by parameter expressions must not be able to see declarations in the body. Without the split, a default value could capture a var from a body that has not run yet.
That behaviour is not guessable and it is in very few tutorials. It took about three minutes to find.
Reading
Required
Recommended
- You Don't Know JS: Scope & Closures
- MDN: Memory management
- MDN: AbortSignal — the
signaloption onaddEventListeneris worth ten minutes