JavaScript Hoisting
Hoisting is JavaScript’s behaviour of “moving” declarations to the top of their containing scope before code runs. The classic explanation is misleading though — nothing actually moves. What really happens is that the engine processes all declarations in a scope first, then executes the statements. The result looks like hoisting. The rules differ between var, let/const, functions, and classes — and getting them wrong leads to confusing undefineds and ReferenceErrors.
What Actually Happens
When a function (or script) starts running, the engine does a quick two-pass:
1. Scan the scope for declarations:
- var x → create binding, initialise to undefined
- let x → create binding, leave UNINITIALISED (TDZ)
- const x → same as let
- function f() → create binding, fully initialise to the function
- class C {} → create binding, leave uninitialised (TDZ)
2. Execute statements top to bottom.
That’s hoisting in one sentence: declarations are processed before statements run.
var — Hoisted and Initialised to undefined
console.log(x); // undefined — not ReferenceError!
var x = 5;
console.log(x); // 5
// What the engine effectively sees:
// var x;
// console.log(x); // undefined
// x = 5;
// console.log(x); // 5
The declaration is hoisted; the assignment is not. That’s why you get undefined and not the post-assignment value.
var is also function-scoped, not block-scoped:
function example() {
if (true) {
var x = 10;
}
console.log(x); // 10 — leaked out of the if
}
This is the chief reason to never use var in new code.
let and const — Hoisted but in the TDZ
let and const declarations are also hoisted, but they’re not initialised until the declaration line runs. The window between scope entry and the declaration is the temporal dead zone (TDZ).
console.log(x); // ❌ ReferenceError: Cannot access 'x' before initialization
let x = 5;
{
// start of block — `y` is hoisted but uninitialised (TDZ begins)
// typeof y; // ❌ ReferenceError — even typeof throws!
let y = 1; // TDZ ends here
console.log(y); // 1
}
The TDZ is a feature: it forces declare-before-use.
Function Declarations — Fully Hoisted
A function declaration is hoisted with its body. You can call it above its definition.
greet(); // "Hello!"
function greet() {
console.log("Hello!");
}
Function expressions are not — they’re just let/const/var assignments where the right side happens to be a function:
sayHi(); // ❌ TypeError: sayHi is not a function
var sayHi = function () { console.log("hi"); };
sayBye(); // ❌ ReferenceError: Cannot access 'sayBye' before initialization
const sayBye = function () { console.log("bye"); };
Class Declarations — Hoisted but in the TDZ
Like let:
new Animal(); // ❌ ReferenceError
class Animal {
constructor() { this.species = "?"; }
}
The binding Animal exists from the top of the scope, but it can’t be used until the class definition runs.
The Definitive Table
| Declaration | Binding hoisted? | Initialised? | Behaviour before declaration line |
|---|---|---|---|
var x = 1 |
✅ | to undefined |
undefined |
let x = 1 |
✅ | ❌ (TDZ) | ReferenceError |
const x = 1 |
✅ | ❌ (TDZ) | ReferenceError |
function f() {} |
✅ | to the function | callable |
class C {} |
✅ | ❌ (TDZ) | ReferenceError |
import X from … |
✅ | depends on cycle | usually fine (modules) |
Function Expressions and Arrows
console.log(typeof regular); // "function"
function regular() {}
console.log(typeof expr); // "undefined" (var declaration only)
var expr = function () {};
// console.log(typeof arrow); // ❌ ReferenceError (let in TDZ)
const arrow = () => {};
| Form | Hoisted as | Available before declaration? |
|---|---|---|
function f() {} |
full function | ✅ |
var f = function() {} |
var (undefined) |
❌ TypeError if you try to call |
let f = … or const f = … |
TDZ | ❌ ReferenceError |
Duplicate Declarations
var x = 1;
var x = 2; // OK — silently re-declared
let y = 1;
let y = 2; // ❌ SyntaxError: Identifier 'y' has already been declared
const z = 1;
z = 2; // ❌ TypeError: Assignment to constant variable
var’s tolerance of re-declaration is yet another reason it’s been replaced.
Hoisting and Scope Combined
function outer() {
console.log(x); // undefined (var x hoisted in this scope)
if (true) {
var x = 1;
let y = 2;
}
console.log(x); // 1 (var leaked out — function-scoped)
console.log(y); // ❌ ReferenceError (let stayed in the if-block)
}
outer();
A Realistic Example — Why It Bites in the Wild
// Buggy lib code from circa 2015
function setupHandlers(buttons) {
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", function () {
console.log("clicked", i); // always logs buttons.length
});
}
}
var i is hoisted to the top of setupHandlers — one binding shared by all closures. By the time any click fires, i is buttons.length. The modern fix:
function setupHandlers(buttons) {
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", () => {
console.log("clicked", i); // correct per-button index
});
}
}
let creates a fresh binding per iteration. Done. (Closures chapter has the full story.)
What Doesn’t Get Hoisted
// Assignments are not hoisted
console.log(x); // undefined (var x; was hoisted, assignment was not)
var x = 5;
// Imports are tricky — bindings are live, but execution is top-to-bottom
import { thing } from "./mod.js"; // mod.js code runs first, before this file's top-level
// Side-effects of function expressions / class declarations stay in place
console.log(C); // ❌ ReferenceError
class C { static counter = console.log("class side effect"); }
Strict Mode and Modules
ES modules (and any 'use strict' script) treat assigning to an undeclared name as a ReferenceError rather than silently creating a global. Combined with let/const, almost all hoisting surprises become loud errors at run time.
'use strict';
oops = 10; // ❌ ReferenceError: oops is not defined
Tip: Modules are strict by default and don’t pollute the global. In 2026 your default should be “module-first” — .mjs, <script type="module">, or "type": "module" in package.json.
Common Mistakes
// ❌ Relying on hoisting to call below a function expression
sayHi();
const sayHi = () => "hi"; // ReferenceError
// ❌ Using a let-declared name in a default param expression that references itself
function f(x = x) { return x; } // ReferenceError — x is in TDZ during evaluation
// ❌ Surprised by var leakage
function f() {
for (var i = 0; i < 10; i++) {}
console.log(i); // 10 — still here
}
// ❌ typeof on a let in TDZ
{
typeof x; // ❌ ReferenceError, despite typeof's usual forgiveness
let x;
}
Best Practices Checklist
- ✅ Use
letandconstexclusively in new code - ✅ Declare variables at the top of the smallest scope they’re used in
- ✅ Define functions before calling them — readability beats hoisting tricks
- ✅ Run in strict mode (modules are automatic)
- ✅ Use
constby default; switch toletonly when reassignment is needed - ❌ Don’t depend on
varhoisting — it’s a bug factory - ❌ Don’t rely on calling a function-expression-assigned name above its definition
- ❌ Don’t redeclare names within the same scope
What’s Next?
You understand where and when variables exist. Now we move to a syntax sugar that makes everyday data work cleaner — destructuring — which pulls properties out of objects and elements out of arrays in a single line.