B2 · Part 1 · Beginner
Execution context
What JavaScript does with your code before it runs a single line of it.
08On this page
Your code is read twice
The sentence that unlocks most of JavaScript's "weird" behaviour is this: JS does not start at line 1. Before a single statement runs, it walks the whole scope once and builds memory for it. Only then does it go back to the top and start executing.
Two phases, always in this order:
- Creation phase — scan the code, find every declaration, put each name into memory, and give it a starting state. Nothing is running yet.
- Execution phase — now run the statements top to bottom. Assignments happen here, and only here.
Every confusing beginner result — a variable that is
undefined instead of erroring, a function you can call
above where you wrote it, a let that throws on a line
that looks harmless — is a fact about phase one.
Phase
Memory — this scope's names
Console
What a context actually holds
The box JS builds to run a chunk of code in has a name: an execution context. You never touch one directly, but three things live inside it and all three are things you already argue with.
Those two arrows point in opposite directions, and mixing them up is
the classic mistake. The outer reference is lexical: read the
source and you know it. this is not — for a normal function it
is filled in fresh on every call, from the shape of that call:
user.greet() gives user, a bare
greet() gives nothing useful. Only an arrow function
skips that step and borrows the this of the scope it was
written in. The rules are in
the this chapter; the one-line
version is "the call site decides".
The global context gets a this too, and it depends on how
the file was loaded: window at the top of a classic
<script>, undefined at the top of an
ES module, and module.exports at the top of a CommonJS
file in Node. Same source, three answers — which is why top-level
code should never lean on it. Use globalThis when you
genuinely mean the global object.
You meet two kinds immediately. The global execution context is created once when the file loads — that is the one the demo above was walking. A function execution context is created fresh on every call, not once per function. Call a function three times and you get three contexts, each with its own memory, created independently.
Say it like this → "A context is not the
function. It is one run of the function."
Hoisting is not code moving upwards
The usual explanation — "declarations get moved to the top of the
file" — is a lie that happens to predict the right answer about half
the time. Nothing moves. The creation phase simply got there first.
The difference matters the moment let is involved,
because the moving story predicts undefined and the real
mechanism predicts a crash.
Here is what the creation phase does, per kind of declaration:
| You wrote | Name exists before the line runs? | Holding what? | Touching it early gives |
|---|---|---|---|
var x = 1 |
yes | undefined |
undefined — silent, no error |
let x = 1 |
yes | nothing — uninitialised | ReferenceError |
const x = 1 |
yes | nothing — uninitialised | ReferenceError |
function f() {} |
yes | the entire function | it just works |
var f = function () {} |
yes | undefined |
TypeError: f is not a function |
class C {} |
yes | nothing — uninitialised | ReferenceError |
x = 1 (no keyword) |
no | — | creates a global at runtime, or throws in strict mode |
Read the last two rows together and you have the single most common
beginner bug in this area: ReferenceError means the name
does not exist yet; TypeError: not a function means the
name exists fine, it just happens to hold undefined right
now. Two different errors, two different fixes.
f(); // works — declaration, stored during creation
g(); // what error do you expect here?
function f() { console.log("f ran"); }
var g = function () { console.log("g ran"); };
Run it. f() is fine. g() throws
TypeError, not ReferenceError — because g exists,
holding undefined, and undefined() is not a
callable thing.
When a var and a function share a name
The table implies a tie-break, and interviewers love asking for it.
If one scope has both var foo and
function foo() {}, creation phase handles the function
first and stores the whole body. The var then finds the
name already taken and does nothing — it never resets it to
undefined. Order in the file does not matter.
console.log(typeof foo); // "function" — the declaration won creation
var foo;
function foo() {}
console.log(typeof foo); // "function" — a bare var foo assigns nothing
var foo = 1;
console.log(typeof foo); // "number" — but an assignment still runs in phase two
The function wins the creation phase; any = still wins
the execution phase. (With let foo in place of
var foo there is no contest — it is a SyntaxError.)
Dry run: creation phase first, then three lines of execution
| Step | Line | foo holds | typeof foo |
|---|---|---|---|
| 1 | creation phase | the function foo(){} — declarations are stored whole, and var foo finds the name already taken and does nothing | — |
| 2 | console.log(typeof foo) | still the function | "function" |
| 3 | var foo; | unchanged — a bare var with no = assigns nothing | — |
| 4 | function foo() {} | unchanged — already stored in creation | — |
| 5 | console.log(typeof foo) | still the function | "function" |
| 6 | var foo = 1; | 1 — this is an assignment, and assignments run in the execution phase | — |
| 7 | console.log(typeof foo) | 1 | "number" |
Only line 6 changes anything. Everything before it is either the
creation-phase tie-break (function beats var) or a
no-op var declaration with nothing to assign — proof
that "declared twice" and "assigned twice" are different questions.
The Temporal Dead Zone, precisely
The TDZ is not "before the declaration line" in the file. It is the span between two moments: the name being created (when the scope is entered) and the name being initialised (when its declaration statement actually runs). Any read in between throws.
{
// ---- b's TDZ starts here, at the { ----
console.log(a); // undefined — var was pre-filled
console.log(b); // ReferenceError: Cannot access 'b' before initialization
var a = 1;
let b = 2; // ---- b's TDZ ends on THIS line ----
console.log(b); // 2 — perfectly fine from here on
}
⚠ typeof does not save you
typeof someNameThatWasNeverDeclared is famously safe — it
returns the string "undefined" instead of throwing. That
exception does not apply inside a TDZ:
typeof b before let b throws a real
ReferenceError. A declared-but-uninitialised name is a stricter thing
than a name that was never mentioned at all.
It exists on purpose. Without it, const would be
observable in an unassigned state, which makes "a const always has its
value" untrue. And the error it produces points at the real mistake —
using something before you set it up — instead of quietly handing you
undefined and letting the damage surface four functions
later.
var, let and const are next
The table above already holds the part of var,
let and const that belongs to this chapter:
what each one holds before its line runs. Everything else about them —
block scope, why a var loop and a let loop
print different numbers, what const actually locks,
redeclaration, and what happens at the top of a file — has
a chapter of its own, straight after
this one.
A fresh context per call
Everything above described one scope. Call a function and the same two-phase process happens again, in a brand-new context, with its own memory:
function counter() {
var n = 0; // created fresh on every single call
n++;
return n;
}
console.log(counter(), counter(), counter()); // 1 1 1 — not 1 2 3
Three calls, three contexts, three separate n boxes, each
born at the start of the call. The question of where these contexts
are stacked — and what happens when one of them never finishes — is
One thread, one stack.
A function's creation phase has one job the global one does not:
before any declaration inside the body is looked at, the
parameters are created and filled with the values the caller
passed. A parameter nobody passed gets undefined. Normal
(non-arrow) functions also get an arguments object at
this point, holding every value that was passed, however many
parameters you wrote.
function f(a, b) {
console.log(a, b, arguments.length); // 1 undefined 1 — set up before line 1 of the body
var a; // finds a already there, changes nothing
console.log(a); // still 1
}
f(1);
Same tie-break as before: a var with the same name as a
parameter is ignored. A function a() {} inside the body
would replace it, though — function declarations are stored after the
parameters, and they overwrite.
⚠ "Thrown away" has an exception
The call ends and its context leaves the stack — but the
memory it built is only freed if nothing still points at it.
Return an inner function that uses
n, and that function
keeps the whole environment alive for as long as it exists. That is
a closure, and it is exactly how makeCounter in
the closures chapter counts up instead
of printing 1 1 1. What that costs in memory is covered in
Engine & memory.
Looking a name up: the scope chain
When a line uses a name that is not in the current context's memory,
JS follows the outer reference one level out and looks again, then
again, until it either finds the name or runs out of scopes and throws
ReferenceError. It never searches inwards, and it never
searches sideways.
const level = "global";
function outer() {
const tool = "hammer";
function inner() {
console.log(tool); // not here -> found one level out
console.log(level); // not here, not there -> found in global
console.log(nope); // nowhere -> ReferenceError
}
inner();
}
outer();
The chain is built from where the function was written, not from who called it. That word is lexical, and it is the reason you can read a function's source and know what it can see without knowing anything about the call site. This is only a first look: every kind of scope, the lookup step by step, shadowing, and what happens when you write to a name the chain cannot find are in Scope, two chapters on.
The traps, collected
-
typeofas a safety check on alet— it throws in the TDZ, so it is only safe for names that were never declared at all. -
Calling a function expression above its line —
TypeError: not a function, because the name exists and holdsundefined. -
Assigning without a keyword —
count = 0in a non-strict script creates a global from anywhere in the codebase. Modules are strict by default, so it throws instead. Good. -
A
varand a function sharing a name — the function wins the creation phase, so the name holds a function until an=runs, notundefined.
Say it like this → "JS builds memory for
a scope before it runs it.
var gets pre-filled with
undefined, let and const get created but
left empty until their line runs — and that empty gap is the TDZ."
Interview answer, one sentence
"Before running, the engine makes a pass that creates a binding for every declaration in a new execution context — var starts as undefined, function declarations are fully usable, and let, const and class stay in the temporal dead zone until their line runs — which is all hoisting means; nothing actually moves."
Before you move on
- Fill in the creation-phase table from memory for
var,let,const, a function declaration, and a function expression assigned to avar. - Explain why calling a function expression too early throws
TypeErrorwhile calling an undeclaredletthrowsReferenceError— and why those are different bugs with different fixes. - Predict the tie-break when a
varand afunctionshare a name in one scope, and say what changes once an=runs. - Say precisely what the Temporal Dead Zone spans — not "above the line in the file," but the gap between a name being created and being initialised.
- Explain why
counter()called three times returns1 1 1instead of1 2 3, in terms of execution contexts.
Opens in the editor: write it, run it, and check it against real tests.
All 35 for this level →Finished reading?
0 of 41 chapters read in this series