What exactly is the temporal dead zone in JavaScript, and when does a let or const binding enter and leave it?
answer
- exists, but holds nothing yet
- starts when the scope is entered
- ends at its own declaration line
- time window, not a text region
- writing to it throws as well
basics
~20 sThe 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.
solid answer
~40 sA `let`, `const`, or `class` binding is created when its scope is instantiated, but unlike `var` it is left uninitialized. From that moment until its declaration statement is actually evaluated, every read or write of the name throws `ReferenceError`. That window is the temporal dead zone. It ends per binding, not per scope: `let a = 1;` takes `a` out of the TDZ while a `let b` further down is still in it. A bare `let x;` leaves the TDZ initialized to `undefined`; `const` must have an initializer, so it always leaves with a value. The crucial word is *temporal* — the zone is about **when** code runs, not where it is written, so a function defined above the declaration works fine as long as it is called after.
code
javascript · 11 linesfunction read() { return limit; }
try {
read(); // called during the TDZ
} catch (e) {
console.log(e.constructor.name); // ReferenceError
}
let limit = 10;
console.log(read()); // 10 - same function, called after initializationgo deeper
Know the shape of the answer: between entering a scope and reaching the let or const line, the name exists but touching it throws a ReferenceError. Name that window the temporal dead zone.
Explain both boundaries precisely — created uninitialized at scope instantiation, initialized when its own declaration is evaluated — and note it ends per binding, with a bare let x; landing on undefined.
Demonstrate the temporal-not-spatial point with a closure that succeeds or throws purely by when it is called, and connect it to why the failure looks intermittent and lands far from the declaration.
Frame the design rationale: pre-initializing lexical bindings would break the guarantee that a const only ever holds its assigned value, so the language pays a runtime throw to keep initialization order honest.
## The three states of a binding Most mental models have two states for a variable: it exists, or it does not. JavaScript has three. 1. **Not declared** — no binding anywhere in the scope chain. Reading it throws `ReferenceError: x is not defined`. 2. **Declared and uninitialized** — the binding exists in an Environment Record but holds no value at all. Reading *or writing* it throws `ReferenceError: Cannot access 'x' before initialization`. 3. **Declared and initialized** — the binding holds a value, possibly `undefined`. State 2 is the temporal dead zone. `var` bindings skip it entirely: they go straight to state 3 with the value `undefined`. `let`, `const`, and `class` declarations pass through it. ## When the TDZ begins It begins at **scope instantiation** — the moment the engine enters the script, function body, or block and builds its Environment Record, before any statement of that scope runs. Every `let`, `const`, and `class` name declared anywhere in that scope gets a slot then, marked uninitialized. This is why a lexical declaration further down a block shadows an outer binding for the whole block: ```js const mode = 'outer'; { console.log(mode); // ReferenceError - the inner binding already shadows the outer one const mode = 'inner'; } ``` The outer `mode` is not reachable inside the block at all. The inner binding exists from the opening brace; it is just uninitialized. ## When the TDZ ends It ends when that binding's **declaration is evaluated** — specifically when the engine runs the LexicalBinding and calls InitializeBinding on the slot. Concretely: - `let x = compute();` — the initializer runs first, then the binding is initialized to its result. If `compute()` itself reads `x`, that read still throws. - `let x;` — no initializer, so the binding is initialized to `undefined` at that statement. Before it, reads throw; after it, reads give `undefined`. - `const x = 1;` — `const` without an initializer is a *SyntaxError*, so a `const` binding always leaves the TDZ holding a real value. - `class C {}` — the class binding is uninitialized until the class definition is evaluated, so `new C()` written above the class declaration throws. It ends **per binding**, not per scope. Two declarations in the same block leave the TDZ at two different moments. ## Temporal, not spatial The name is precise and worth defending in an interview: the zone is a stretch of *time*, not a region of *source text*. Code written above a declaration is not automatically doomed — it only fails if it *runs* before the declaration is evaluated. ```js function read() { return limit; } // written above the declaration let limit = 10; console.log(read()); // 10 - read() is called after limit is initialized ``` Move the call up one line and the same function throws: ```js function read() { return limit; } console.log(read()); // ReferenceError: Cannot access 'limit' before initialization let limit = 10; ``` The function body never changed. Only the moment of invocation did. This is exactly why the bug is confusing in real code: the throwing line is often nowhere near the declaration, and the same function works on a later call. ## Writes throw too The TDZ is not read-only protection. Assigning to the binding before its declaration throws just as hard: ```js try { count = 5; } catch (e) { console.log(e.constructor.name); } // ReferenceError let count; ``` That matters because it rules out the workaround people reach for first — "I'll just assign it earlier." ## Why the design is deliberate The TDZ exists so that `const` can mean something. If lexical bindings were pre-initialized to `undefined` like `var`, a `const` would be observable holding a value it was never assigned, and the promise "a `const` binding always has the value you gave it" would be false for the top of its scope. Making early access throw preserves that guarantee, and as a bonus turns use-before-initialize — a genuine ordering bug — into a loud, precisely located error instead of a silent `undefined` travelling through the program. ## What an interviewer is listening for The three states, the two boundaries (scope entry, declaration evaluation), "per binding, not per scope", and above all the temporal-not-spatial point demonstrated with a closure that works or throws depending only on when it is called.
- Does a function written above a let declaration always fail when it reads that variable?No — the zone is temporal. If the function is invoked after the declaration has been evaluated, the binding is initialized and the read succeeds. It throws only when the call happens during the dead zone. That is why the same helper can work on one call and throw on an earlier one, which makes the bug look intermittent.
- Is the temporal dead zone read-only protection, or does assignment throw too?Both throw. Any reference to an uninitialized binding — read or write — raises `ReferenceError`. So `count = 5;` placed above `let count;` fails exactly like a read would. There is no way to seed the binding early; the only fix is to move the access after the declaration, or move the declaration up.
- What happens with `let x;` that has no initializer — is it in the TDZ at all?Yes, until that statement runs. The binding is uninitialized from scope entry, so a read above the line throws. Evaluating `let x;` initializes it to `undefined`, after which reads succeed and give `undefined`. `const` has no such case: omitting the initializer is a SyntaxError, so a const always leaves the zone holding a value.
saying these in an interview costs you the question
- Says let and const are simply not hoisted
- Thinks the TDZ ends at the end of the block
- Believes only reads throw and assignment is allowed
- Claims code written above a declaration always throws
- Treats an uninitialized binding as holding undefined