lainlog
back

JavaScript Hoisting, the TDZ, and the Call Stack Explained

·15 min read

How JavaScript reads its own future

You type two lines into a console: console.log(x); var x = 5;. You expect an error — line 1 reads a name that line 2 hasn't introduced yet. Instead, the console quietly logs undefined. The error never comes. Line 1 ran with line 2 already known.

1 · the pre-walk

The engine reads your file before it runs line 1.#

The trick isn't that var moved anywhere. Nothing moves. The engine walks the body once before any of it runs. On that first pass — call it the creation phase — every var, every let, every function declaration, every parameter gets a binding installed in the function's memory. Only after the whole body has been scanned does line 1 actually start.

Drag the scrubber below — past the labelled boundary. To the left, the engine is still scanning; the binding for x is already there with value undefined, but no source line has run. To the right, line 1 finally executes. It reads what was installed on the left.

creation vs execution · var x = 5
creation vs execution · var x = 5
creation phase. No source line has run. The binding for x is already installed with value undefined.

2 · what the pre-walk records

Different declarations arrive at line 1 in different shapes.#

The pre-walk isn't uniform. It looks at the syntactic kind of each declaration and decides what state the binding is in when execution begins. Four cases cover almost everything you'll ever hit.

A var binding is created and initialised to undefined— that's the case the opening scene showed. A function declaration goes further: the binding exists and already points at the function value, which is why f() can call f two lines above the function f() that defines it.

A var x = function() is the deceptive one. Only the var x = undefined half survives the pre-walk. The function value attaches when the assignment line runs, so calling f() above it throws TypeError.

And then there's let, const, and class. Their bindings are created on the pre-walk too, but they arrive at line 1 in a fourth state: uninitialised. The TC39 spec has a name for it — the temporal dead zone — and a rule for it: the binding exists, but reading it throws.

Switch declaration kinds in the widget below to see the four memory shapes side by side. The hatched cell is the TDZ — the binding isn't missing, it's on hold.

declarations · at line 1
declarations · at line 1
var — binding installed, value undefined. Reading it returns undefined — no error.

One detail worth keeping: a let inside a block creates its binding in a fresh inner lexical environmentattached to that block, not in the function's top-level memory. That's why { let x = 2 }doesn't collide with an outer let x — same name, different environment record. See MDN's hoisting catalogue for the full taxonomy.

3 · the call stack, plural

The call stack is a stack of execution contexts, not function calls.#

Hoisting, the TDZ, and the question the opening scene started with all live inside one frame: the global execution context. Every script begins with that frame at the bottom of a stack. The frame is a structure, not a name on a list — it carries its own variable environment (where var and function-declaration bindings live) and its own lexical environment (where let, const, and block scopes live).

When a function is invoked, the engine pushes a new execution context on top. That new frame runs its own creation phase — parameters, vars, function declarations, the TDZ rules — and starts executing line 1 of the function body. When the function returns, its frame pops, and whichever frame is now on top resumes wherever it had paused.

Step through the widget below to watch a tiny program run: compute(7) calls multiply(7, 2), which returns 14. The highlighted line and the highlighted frame are paired — the line being run, and the context running it. Each call pushes a new EC onto the stack; each return pops one. Whichever frame is on top is the engine's thread of execution.

call stack · execution contexts
call stack · execution contextsstep 1/7
global ec is on top. Pre-walk done. The Global EC holds bindings for multiply, compute, and answer (still uninitialised).

Each function call gets its own execution context — its own private memory and its own slot on the stack. The thread of execution moves into a new context when a function is called, and back to the previous one when it returns. That's also the answer to the opening scene. The line that printed undefined was running inside the global EC. Its variable environment already held x: undefined, courtesy of the pre-walk. The console asked memory; memory answered.

4 · why two passes

The two-pass model is required.#

A reasonable question at this point: couldn't the engine just run the file top to bottom and look up names as it goes? It would be simpler. It would also be wrong — three different ways. Toggle through the reasons below.

two passes · why
two passes · why
Function declarations must be callable from anywhere in scope. That guarantee is the spec — and it forces the engine to install all function bindings before line 1.

Three constraints, three categories. Semantic: function declarations must be callable from anywhere in scope. Syntactic: duplicate lets must throw a SyntaxErrorbefore any line runs — V8's preparser was extended specifically to catch them. Immutability: a constcan't read as undefined first and bind later. The TDZ resolves all three: required, not an optimisation.

Hoisting, the TDZ, functions callable from above — three names for one mechanism. The opening scene wasn't a quirk. It was the mechanism showing through.

The engine reads your future to run your present.