skip to content

Hoisting and the Temporal Dead Zone

What hoisting really means — bindings created when the scope is entered, not code moved to the top — and why touching a let before its line throws instead of yielding undefined. The TDZ is the follow-up that separates recall from understanding.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

4

What does this JavaScript script print, and why? `console.log(a); var a = 1; console.log(b); let b = 2;`

level: juniorimportance: must knowfreq 78%

answer

  1. bindings created, code not moved
  2. var gets a starting value
  3. let starts with no value at all
  4. reading it throws, not undefined
  5. assignments still run in order

basics

~20 s

Logs undefined, then throws a ReferenceError. Both bindings already exist when the script starts, but a var binding is pre-initialized to undefined, while a let binding is left uninitialized until its own declaration line runs.

solid answer

~40 s

The first `console.log` prints `undefined`; the second throws `ReferenceError: Cannot access 'b' before initialization`, so `let b = 2;` never executes. Before any statement of a scope runs, the engine creates a binding for every declaration in that scope. A `var` binding is created **and initialized to `undefined`** at that moment, so reading it early is legal and gives you `undefined`. A `let` or `const` binding is created at the same moment but deliberately left **uninitialized**; reading or writing it before its declaration statement is evaluated throws. That window is the temporal dead zone. Note that only the binding is set up early — the assignments `= 1` and `= 2` are ordinary statements that still run in source order.

code

javascript · 14 lines
javascript
function demo() {
  console.log(a); // undefined - var binding pre-initialized
  var a = 1;

  try {
    console.log(b); // throws - let binding still uninitialized
  } catch (e) {
    console.log(e.constructor.name); // ReferenceError
  }
  let b = 2;

  console.log(a, b); // 1 2
}
demo();

go deeper

for a junior

Be ready to state the output plainly: var reads before the line give undefined, let reads before the line throw a ReferenceError. Add that both names already exist when the scope starts.

for a middle

Explain the mechanism, not the folklore: entering a scope creates a binding for every declaration, var bindings pre-initialized to undefined and let/const left uninitialized. Stress that the assignment itself never moves.

for a senior

Show why it matters in production code: a var read too early yields a silent undefined that surfaces as NaN or a crash far away, while a const read too early fails at the exact line. Say which failure mode you would rather ship.

for a principal

Own the standard you set: mandating const/let and linting var out converts a whole class of silent initialization bugs into loud startup failures, which is a deliberate trade of runtime crashes for debuggability.

## What the snippet prints ```js console.log(a); // undefined var a = 1; console.log(b); // ReferenceError: Cannot access 'b' before initialization let b = 2; ``` The first log succeeds and prints `undefined`. The second one throws, so execution stops there and `let b = 2;` never runs. The exact wording of the error is engine-specific — V8 says "Cannot access 'b' before initialization", SpiderMonkey says "can't access lexical declaration 'b' before initialization", JavaScriptCore says "Cannot access uninitialized variable" — but every engine throws a `ReferenceError`. ## Bindings are created when the scope is entered Before a single statement of a script, function body, or block executes, the engine scans that piece of code and builds an *Environment Record*: a table mapping every name declared in the scope to a storage slot. The specification does this in abstract operations named GlobalDeclarationInstantiation, FunctionDeclarationInstantiation, and BlockDeclarationInstantiation. That setup pass is what people are pointing at when they say "hoisting". The key correction to the folklore: **nothing is moved**. The source is not rewritten and no statement is relocated to the top. The *names* simply come into existence before the lines that declare them are reached. So in the snippet above, both `a` and `b` exist as bindings of the script scope from the instant the script starts running. What differs is the state each slot is in. ## var: created and pre-initialized to undefined A `var` binding is created *and initialized to the value `undefined`* during that setup pass. That is why line 1 is legal: the slot exists and already holds a real value, so `console.log(a)` prints `undefined`. Later, when control reaches `var a = 1;`, the assignment part runs and the slot is updated to `1`. This is the source of the classic "why is my variable undefined?" bug. There is no error and no warning — just a value that is silently wrong until the assignment line executes. ```js function f() { console.log(count); // undefined, not an error var count = 3; console.log(count); // 3 } ``` ## let and const: created but left uninitialized A `let`, `const`, or `class` binding is created during the same setup pass, but is deliberately left in an *uninitialized* state — a third state distinct from "does not exist" and "holds undefined". Any read or write of the binding while it is uninitialized throws a `ReferenceError`. The window between entering the scope and evaluating the declaration is the **temporal dead zone (TDZ)**. The binding leaves the TDZ when its declaration statement is evaluated. `let b = 2;` initializes it to `2` at that point; a bare `let b;` initializes it to `undefined` at that point; `const` cannot be written without an initializer at all, so it is always initialized by its declaration. ## The assignment is never hoisted A frequent half-understanding is "the declaration and its value move to the top". Only the *binding* is set up early. This is why the following prints `undefined` rather than `1`: ```js var x = 'outer'; function g() { console.log(x); // undefined — the inner var x shadows the outer one for the whole function var x = 'inner'; } g(); ``` The inner `var x` binding exists for the entire function body, so the outer `x` is not visible inside `g` at all, and its value is `undefined` until the assignment runs. ## Why the throw is the better behaviour `var`'s pre-initialization turns a genuine ordering mistake — using a value before you have computed it — into a silent `undefined` that propagates: it flows into string concatenation as `"undefined"`, into arithmetic as `NaN`, and into a property access as a crash somewhere far from the cause. The TDZ makes the same mistake fail loudly, at the exact line and with the offending name in the message. That is the practical reason modern code uses `const` by default, `let` when reassignment is needed, and lint rules that ban `var`. ## What to say in an interview State the output first (`undefined`, then a `ReferenceError`), then give the one-sentence mechanism: every declaration in a scope gets a binding when the scope is entered; `var` bindings start out holding `undefined`, `let`/`const` bindings start out uninitialized and throw until their declaration runs. Finish by pointing out that the *assignments* never move — that detail is what separates a memorised answer from an understood one.

  • If the assignments are not hoisted, what exactly is set up before the first statement runs?
    The bindings themselves. Entering a scope creates an Environment Record with one slot per declared name. `var` slots are initialized to `undefined` at that moment; `let`, `const`, and `class` slots are created uninitialized and throw on access until their declaration is evaluated. The initializer expressions run later, in source order, exactly where they are written.
  • Why did the language designers make let throw instead of just giving it undefined like var?
    Because reading a value before it has been computed is almost always a bug, and `undefined` hides it: it silently becomes `NaN` in arithmetic or `"undefined"` in a string, and the crash surfaces far from the cause. Throwing a `ReferenceError` naming the binding reports the mistake at the exact line, which is why lexical declarations fail fast by design.
  • What happens if you write `let b;` with no initializer and read it before that line?
    It still throws. The binding is uninitialized from scope entry until the `let b;` statement is evaluated — only then is it initialized to `undefined`. So `console.log(b); let b;` throws a `ReferenceError`, while a read placed after that line logs `undefined`.

saying these in an interview costs you the question

  • Says declarations are physically moved to the top of the file
  • Claims let and const are not hoisted at all
  • Expects the let read to log undefined like var
  • Thinks the assigned value is hoisted with the declaration
  • Says the whole script throws, so nothing is printed

context

open as a page

What exactly is the temporal dead zone in JavaScript, and when does a let or const binding enter and leave it?

level: middleimportance: must knowfreq 62%

basics

~20 s

The temporal dead zone is the window in which a let, const, or class binding exists but is uninitialized, so any access throws a ReferenceError. It begins when the scope is entered and ends when that binding's own declaration is evaluated.

open as a page

Before ES2015, `typeof x` was a safe way to probe a variable that might not exist. What changed once let and const arrived, and when does typeof now throw?

level: middleimportance: should knowfreq 44%

basics

~20 s

typeof is still safe for a completely undeclared name — it returns the string "undefined". But if the name is declared with let, const, or class in an enclosing scope and is still in its temporal dead zone, typeof throws a ReferenceError like any other access.

open as a page

A JavaScript app throws `ReferenceError: Cannot access 'config' before initialization` at startup. What does that specific message tell you, and how do you track down the cause?

level: seniorimportance: should knowfreq 38%

basics

~20 s

That message means the binding exists in scope but is still uninitialized: something declared with let, const, or class was accessed before its declaration ran. Find what executes during the scope's setup and reaches the name early, rather than looking for a missing declaration.

open as a page