Hoisting
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ The stage manager's pre-show checklist โ everything is catalogued before the curtain rises.
The real-world analogy
Before a theatre show opens, the stage manager walks every inch of the stage and writes a property list: which props exist, which are loaded and ready, which are still in the truck. When the show starts, the actors know exactly what they can pick up โ and what will explode in their hands if they try too early.
JavaScript's engine does the same thing before it runs your code. It scans every scope, registers every declaration, and decides what's "ready" vs "still in the truck."
The actual mechanism โ not "code moving"
Hoisting is not the engine physically moving your declarations to the top of the file. That's the simplified mental model most tutorials use. What really happens:
- Parse phase: Engine builds the Abstract Syntax Tree (AST) from your source code.
- Binding instantiation: Engine walks the AST and creates memory slots (bindings) in Environment Records for every declaration it finds. This happens before any code runs.
- Initialization policy differs by keyword:
varandfunctionbindings are initialized immediately (undefined and full value, respectively).let,const, andclassbindings are left uninitialized โ in the Temporal Dead Zone. - Execution phase: Code runs line-by-line. Bindings transition from uninitialized โ initialized as their declaration lines are hit.
Three hoisting tiers
Full value hoisting โ function declarations, import
Binding exists AND is fully initialized before any code runs. You can use the function above where it's written.
Declaration hoisting โ var
Binding exists and is initialized to undefined. Using it before the assignment returns undefined silently โ the source of many bugs.
TDZ hoisting โ let, const, class
Binding exists in the Environment Record but is marked uninitialized. Accessing it before the declaration line throws ReferenceError. A feature, not a bug โ it catches Tier 2 problems at development time.
The TDZ proof โ why let/const IS hoisted
Sceptical that let is "hoisted" if accessing it throws? This proves it:
| 1 | const x = 1; // outer x |
| 2 | { |
| 3 | console.log(x); // ReferenceError โ NOT 1! |
| 4 | const x = 2; // inner x |
| 5 | } |
If the inner const x were NOT hoisted, the engine would look up the outer scope and print 1. Instead it throws โ because the inner binding was already registered in the block's Environment Record during binding instantiation, shadowing the outer one and placing it in TDZ. That's proof of hoisting.
Interactive Sandbox
โ Move something, see it react instantly.Declaration type
console.log(x); // โ "before" phase access here
var x = 42; // โ the declaration
console.log(x); // โ "after" phase access here
Execution phase
Environment Record โ binding state
| 1 | x โ undefined โ hoisted, initialized to undefined |
console.log(x) returnsโฆ
Nothing runs yet โ JS is scanning declarations and building the Environment Record.
Challenge
Explore all 6 declaration types at the 'Before decl' phase. See what each one returns โ and why.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q1: "What does this log, and why?"
| 1 | console.log(a); // ? |
| 2 | var a = 10; |
| 3 | console.log(a); // ? |
Answer: undefined, then 10. var a is hoisted and initialized to undefined before execution starts. The assignment = 10 runs at the declaration line.
Q2: "Why does this loop print 3, 3, 3?"
| 1 | for (var i = 0; i < 3; i++) { |
| 2 | setTimeout(() => console.log(i), 0); |
| 3 | } |
| 4 | // Logs: 3 3 3 |
Answer: var i is function-scoped โ all three callbacks close over the same binding. By the time the callbacks fire (after the synchronous loop completes), i is already 3. Fix: use let i (each iteration creates a new block-scoped binding) or an IIFE.
Q3: "What does typeof return for a TDZ variable?"
| 1 | console.log(typeof undeclared); // "undefined" โ safe |
| 2 | console.log(typeof tdz); // ReferenceError! |
| 3 | let tdz = 1; |
Answer: typeof on a truly undeclared variable returns "undefined" safely. But if the variable is declared with let/constand in TDZ, typeof still throws โ TDZ is stricter than undeclared.
Production bugs hoisting causes
Bug: silent undefined for non-premium users
| 1 | function checkout(user) { |
| 2 | if (user.premium) { |
| 3 | var code = "SAVE20"; |
| 4 | } |
| 5 | // 200 lines later... |
| 6 | applyCode(code); // undefined for non-premium โ no error! |
| 7 | } |
var code is hoisted to the function scope as undefined. Non-premium users silently get applyCode(undefined). With let, this would throw ReferenceError and get caught in testing.
Bug: calling a function expression before assignment
| 1 | initApp(); // TypeError: initApp is not a function |
| 2 | ย |
| 3 | var initApp = function() { /* setup */ }; |
The developer expects function-declaration behaviour but wrote a function expression.var initApp is hoisted as undefined โ calling undefined()throws TypeError. Switch to a function declaration or move the call after the expression.
Complexity / at-a-glance table
| Keyword | Hoisted | Init value | TDZ | Scope |
|---|---|---|---|---|
| var | โ | undefined | โ | function |
| let | โ | โจTDZโฉ | โ | block |
| const | โ | โจTDZโฉ | โ | block |
| function | โ | full value | โ | function |
| fn expr | โ | โ | โ | depends on keyword |
| class | โ | โจTDZโฉ | โ | block |
| import | โ | full value | โ | module |
The Deep Dive
โ Spec refs, engine internals, the minutiae.ECMAScript spec: LexicalEnvironment vs VariableEnvironment
Every Execution Context (function call, module, global code) has two environment slots:
- VariableEnvironment โ holds
varbindings and function declarations. Created for the entire function/global scope regardless of blocks. - LexicalEnvironment โ holds
let,const,classbindings. Each block gets its own nested Lexical Environment.
When code enters a block (if, for, {}), the engine creates a new Lexical Environment chained to the outer one. var declarations walk up to the enclosing VariableEnvironment (the function or global), ignoring blocks entirely โ this is why var"escapes" if statements and loops.
TDZ in detail: what "uninitialized" means in the spec
The spec (ECMA-262 ยง9.1) defines binding state as one of: uninitialized, or a value. When a binding is uninitialized, any Get or Set operation on it calls ThrowReferenceError. This is the TDZ.
For let/const/class, bindings are created (instantiated) during the "Declaration Instantiation" algorithm that runs before execution, but their initializationonly happens when the declaration statement is reached in execution order.
| 1 | // This proves TDZ is a BINDING issue, not a scope issue: |
| 2 | let x = "outer"; |
| 3 | { |
| 4 | // Inner block's Environment Record is created |
| 5 | // x โ uninitialized โ already registered, shadows outer x |
| 6 | // Accessing x here reads from inner ER โ throws |
| 7 | typeof x; // ReferenceError (not "string"!) |
| 8 | let x = "inner"; |
| 9 | // Now x โ "inner" |
| 10 | } |
Block-level function declarations โ the dangerous edge case
Function declarations inside blocks (if, loops) have historically inconsistent behaviour across engines. In strict mode, block-level function declarations are block-scoped (like let). In non-strict ("sloppy") mode, the spec introduced legacy compatibility semantics that vary by engine.
| 1 | "use strict"; |
| 2 | { |
| 3 | foo(); // Works โ block-scoped function declaration |
| 4 | function foo() {} |
| 5 | } |
| 6 | console.log(typeof foo); // "undefined" โ not visible outside block |
| 7 | ย |
| 8 | // Non-strict: AVOID โ result varies by engine |
| 9 | if (true) { |
| 10 | function bar() {} |
| 11 | } |
| 12 | console.log(typeof bar); // "function" in V8, may differ elsewhere |
Rule: never write function declarations inside if/for/while blocks. Use function expressions assigned to const instead.
import hoisting โ the strongest form
ES module import statements are fully hoisted with values before any module code runs. More precisely: the entire module graph is evaluated (all imports resolved, all side effects executed) before the importing module's own code starts. This is stronger than function-declaration hoisting.
| 1 | // Even this works: |
| 2 | console.log(add(1, 2)); // 3 โ import is fully live |
| 3 | import { add } from "./math.js"; |
| 4 | ย |
| 5 | // Module evaluation order: ./math.js โ this file โ consumers |
| 6 | // Each module evaluates exactly once (cached after first evaluation) |
Common implementation bugs
- var in try/catch: the
catchparameter (e) is scoped to the catch block, but anyvardeclared inside the catch escapes โ including accidental redeclarations of outer variables. - Redeclaring var after function:
function a() {}followed byvar a = 1;โ the function is hoisted first, then thevarinitializer overwrites it at runtime. The reverse order (var then function) leaves the function value intact if there's no var initializer. - typeof safety net breaks with TDZ: developers sometimes use
typeof x !== "undefined"as a safe feature-detect guard. This is broken ifxis declared withlet/constlater in the same scope โ TDZ makestypeofthrow too. - eval() and hoisting: direct
eval()can injectvarbindings into the surrounding scope. Indirect eval (via a variable reference) runs in global scope. Both are footguns โ avoid entirely.
Linting and prevention
- ESLint: no-var โ enforce
let/consteverywhere - ESLint: no-use-before-define โ catches usage before declaration even when it's technically legal
- ESLint: no-redeclare โ prevents var redeclarations
- TypeScript strict mode โ type flow analysis catches uninitialized usage patterns
- Declare at top of scope โ making your own declarations visible matches how the engine sees them
Interview Questions
โ Real questions from real interviews โ with answers.`undefined` โ var is hoisted and initialized to undefined before execution.
Yes โ it's hoisted into TDZ (Temporal Dead Zone), not to `undefined`.
TDZ is the period from a scope's start to a `let`/`const` declaration where accessing the binding throws.
Function declarations are fully hoisted with their value; function expressions only hoist the variable (as `undefined`).
Class semantics intentionally require the definition to be evaluated before use โ unlike function declarations.
In strict mode, block-level function declarations are block-scoped; in sloppy mode, behavior is engine-dependent and legacy-compat.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is a 'parameter default value' TDZ interaction in JavaScript?