Skip to main content

hoisting in JavaScript

Hoisting is JavaScript's behavior of registering variable and function declarations in memory during compilation, before the first line of code runs.

Theory

TL;DR

  • Think of a roll call before class: the teacher marks everyone present before the lesson starts. Declarations are noted, but actual values come later.
  • Function declarations are fully hoisted. You can call them before they appear in the file.
  • var declarations are hoisted and initialized to undefined. Accessing one before its assignment line returns undefined, not an error.
  • let and const are hoisted but not initialized. Accessing them before their declaration throws a ReferenceError. This gap is called the temporal dead zone (TDZ).
  • Default choice: const for values that won't change, let for variables that will, var only in legacy codebases.

Quick example

javascript
// Function declaration - fully hoisted, this works console.log(add(2, 3)); // 5 function add(a, b) { return a + b; } // var - hoisted as undefined console.log(name); // undefined (not an error) var name = "Alice"; // let/const - temporal dead zone console.log(age); // ReferenceError: Cannot access 'age' before initialization let age = 25;

The engine already knows about add, name, and age before line 1 runs. But only add has its full value at that point.

Key difference

The JavaScript engine makes two passes over your code. In the first pass (compilation), it scans for all declarations and registers them in memory. Function declarations get their full function object right away. var variables get undefined. let and const get registered but stay uninitialized, which is why accessing them early throws instead of quietly returning undefined. That difference is intentional: the TDZ exists to catch bugs that var used to hide.

When to use

  • Function declarations: When you need to call a helper at the top of a file while defining it below. Common in Express route files and Node.js modules.
  • var: Avoid in modern code. The only real case is maintaining code that must run in IE8 or older environments.
  • let/const: Default for all new code. const prevents accidental reassignment; let signals the value will change.

How the engine handles this

V8 (Chrome/Node.js) and SpiderMonkey (Firefox) both do two passes. Pass one: scan the scope, allocate memory, set initial values. Pass two: execute line by line. For var and function declarations, pass one sets a real initial value. For let/const, pass one only registers the name. The value comes in pass two, at the exact declaration line.

Common mistakes

Mistake 1: Expecting var to respect block scope

javascript
function test() { console.log(y); // undefined - not an error if (true) { var y = 10; } console.log(y); // 10 - y is function-scoped, not block-scoped } test();

var ignores block boundaries. y is hoisted to the function scope, so both console.log calls see it. The first returns undefined, the second returns 10. Switching to let makes the first line throw a ReferenceError, which is usually the behavior you actually want.

Mistake 2: Refactoring a function declaration into an arrow function

javascript
// Works today processData(rawData); function processData(data) { return data.map(x => x * 2); } // After a refactor - breaks processData(rawData); // TypeError: processData is not a function const processData = (data) => data.map(x => x * 2);

Arrow functions and function expressions are not hoisted. If someone converts a function declaration to a const, any call above the definition breaks immediately. I've seen this burn teams during refactors where CI passed locally but failed once file order changed in the build.

Mistake 3: The classic var loop closure bug

javascript
// Prints 3, 3, 3 for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // Prints 0, 1, 2 for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); }

var i is hoisted to the function scope. All three callbacks reference the same i, which equals 3 by the time they run. let i creates a new binding per iteration.

Mistake 4: Thinking function expressions are hoisted

javascript
foo(); // TypeError: foo is not a function var foo = function() { return 42; };

var foo is hoisted as undefined. The function body is not. Calling foo() before the assignment is calling undefined().

Real-world usage

  • Express.js: Route handlers as function declarations can reference middleware defined below them in the same file.
  • Node.js modules: Public functions at the top, private helpers at the bottom. Works because function declarations are fully hoisted within the module.
  • Jest/Mocha: describe() blocks can reference test helper functions defined after them.
  • React class components: Lifecycle methods and render() can call helper methods defined anywhere in the class body.

Follow-up questions

Q: What is the temporal dead zone?
A: The TDZ is the period between entering a scope and reaching the let/const declaration line. During this window, the variable is registered but not initialized. Accessing it throws a ReferenceError. This differs from var, which is initialized to undefined the moment the scope starts.

Q: Why does console.log(x) print undefined instead of throwing when var x is declared below?
A: Because var x is hoisted and immediately initialized to undefined. The engine treats it as var x = undefined; console.log(x); x = 5;. The assignment stays on the original line, but the declaration with its default value moves up.

Q: Can you hoist a function expression or arrow function?
A: No. Only function declarations hoist with their full body. Function expressions assigned to var hoist as undefined. Assigned to let/const, they sit in the TDZ. Calling either before the line throws.

Q (Senior): let and const are hoisted but not initialized. Why does the spec bother hoisting them at all?
A: Because hoisting determines scope ownership. If let x is inside a block, JavaScript needs to know that x belongs to that block, not an outer scope. Without registration in pass one, the engine would walk up the scope chain and potentially find an outer x, giving you a value instead of an error. The TDZ guarantees the variable is claimed by its block before becoming accessible.

Examples

Function declaration hoisting inside a module

javascript
// Calling before definition - works because of hoisting const logger = createLogger("APP"); logger("Server started"); // [APP] Server started function createLogger(prefix) { return function(message) { logMessage(message); }; // logMessage is hoisted within createLogger's scope function logMessage(msg) { console.log(`[${prefix}] ${msg}`); } }

createLogger is hoisted to the module scope. Inside it, logMessage is hoisted to createLogger's scope. The returned function can call logMessage safely even though it appears after the return statement.

The var loop closure bug and the fix

javascript
// Classic interview trap function withVar() { for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } } withVar(); // 3, 3, 3 - i is shared across all iterations // Fix with let function withLet() { for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } } withLet(); // 0, 1, 2 - each iteration gets its own i

One of the most common hoisting questions in JavaScript interviews. var hoists i to the function scope, so all three closures capture the same variable. By the time the callbacks run, the loop has finished and i is 3. let creates a fresh binding per iteration.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?