JavaScript Hoisting Explained: var, let, const, and Functions in 2026

Link copied
JavaScript Hoisting Explained: var, let, const, and Functions in 2026

JavaScript Hoisting Explained: var, let, const, and Functions in 2026

JS Tutorial Module 2: Functions Lesson 2.3

Open any JavaScript interview prep list and "hoisting" is in the top five. It is also the single most misunderstood JavaScript concept — and the misunderstandings come from outdated explanations that say JavaScript "physically moves declarations to the top of the file."

It does not. Nothing in your code physically moves. What actually happens is a memory-allocation step that runs before your code executes — and once you see that, hoisting stops being mysterious.

This is Part 2 of the JavaScript Fundamentals series. If you have not read Part 1 on the execution context, do that first — the model we use here is built on it.

The textbook lie #

The outdated explanation says:

During hoisting, JavaScript moves variable and function declarations to the top of their scope.

This is wrong. The engine never edits your source code. Here is what actually happens:

When JavaScript enters an execution context (Part 1), it runs a creation phase before the execution phase. The creation phase scans the entire scope, finds every declaration, and reserves memory for it. The scope's bindings exist before line 1 of your code runs. That is hoisting.

The difference matters because different declaration types are initialized differently during creation. That is where every hoisting gotcha comes from.

Hoisting cheat sheet #

Declaration type Memory allocated at creation? Initialized at creation? Initial value
var x yes yes undefined
let x yes no (TDZ) — (ReferenceError if accessed)
const x yes no (TDZ) — (ReferenceError if accessed)
function foo() {} yes yes the function itself
class Foo {} yes no (TDZ) — (ReferenceError if accessed)
var foo = function() {} yes (for foo) yes undefined (function assigned during execution)
import { x } yes yes the imported binding

Notice the split: var and function declarations are fully hoisted and initialized. Everything else is declared but not initialized until execution reaches them. That gap between declaration and initialization is called the temporal dead zone.

var hoisting in action #

console.log(name); // undefined — not an error!
var name = 'Alice';
console.log(name); // 'Alice'

The creation phase saw var name and reserved memory pre-initialized to undefined. Line 1 logs undefined. Line 2 (execution phase) assigns 'Alice'. Line 3 logs the assigned value.

Mental model:

// What the engine effectively sets up before running:
var name = undefined;          // <-- creation phase

// Then it runs your code in order:
console.log(name);             // undefined
name = 'Alice';                // execution phase assigns
console.log(name);             // 'Alice'

Nothing was physically moved. The binding just existed before your code ran.

Function-declaration hoisting #

Function declarations (the function foo() {} form) get the strongest hoisting — their entire body is available from line 1:

sayHi('Sam'); // 'Hi Sam' — works!
function sayHi(name) {
  return `Hi ${name}`;
}

During creation, the engine stores both the name sayHi and the function body itself. By line 1, calling it is fine.

But function expressions are different:

sayHi('Sam'); // TypeError: sayHi is not a function
var sayHi = function (name) {
  return `Hi ${name}`;
};

Why? Because this is two things happening:

  1. var sayHi — declared and initialized to undefined in the creation phase.
  2. = function (name) { ... } — assignment, happens in the execution phase.

When line 1 runs, sayHi is undefined. Calling undefined() throws.

Arrow functions behave the same way as function expressions:

sayBye(); // TypeError: sayBye is not a function
const sayBye = () => 'bye';

Plus arrow functions are declared with const, which adds the temporal dead zone (more on that next).

The temporal dead zone (TDZ) #

let and const are hoisted too — they just are not initialized. Accessing them before their declaration line throws:

console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;

The variable exists in memory (the engine knows about x), but accessing it is a deliberate error until the let statement runs. The window between "binding created" and "first assignment" is the TDZ.

Why did the language designers add the TDZ? Because var's silent undefined is a footgun. Half of JavaScript's mysterious bugs come from accidentally reading a variable that has not been assigned yet. let and const make those bugs loud.

TDZ also catches typos:

function process() {
  let totalAmount = 0;
  // ... 200 lines later ...
  console.log(totalamount); // ReferenceError ✓
}

With var, that typo would log undefined and you would spend an hour debugging. With let / const, you get a loud error pointing at line 200.

TDZ also includes the same-line case:

let y = y + 1; // ReferenceError

On the right-hand side, y is still in the TDZ. The initialization to + 1 has not completed yet.

Block scope changes everything #

This is the other huge difference between var and let/const:

if (true) {
  var a = 1;
  let b = 2;
}
console.log(a); // 1
console.log(b); // ReferenceError: b is not defined

var is function-scoped (or global) — it leaks out of if, for, while, and other block statements. let and const are block-scoped — they only exist inside { ... }.

This is what makes let correct in the classic for-loop closure trap. We unpack closures fully in Part 4, but here is the preview:

// Broken with var:
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 3 3 3   (one shared i)

// Correct with let:
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 0 1 2   (a fresh i per iteration)

Each iteration of the let loop creates a new block-scoped binding. The arrow function captures the binding for that iteration. With var, there is only one i for the whole loop.

If you understand the execution context model, this is obvious: with let, each iteration enters a new block, which creates a new lexical environment, which holds a separate i. With var, there is no new environment per iteration.

Class declarations are NOT hoisted #

A common trap:

new Animal();  // ReferenceError: Cannot access 'Animal' before initialization
class Animal {}

Class declarations behave like let — they exist in the TDZ until their declaration line is reached. This is intentional. Hoisting a class body would mean its extends clause could reference something not yet defined, which would be nonsense.

What about hoisting and modules? #

In ES modules, import declarations are hoisted and initialized at the top of the module scope. That is why you can call an imported function from anywhere in the file:

import { fetchUser } from './user.js';

// This works even though we wrote the import at the top —
// the import is conceptually hoisted before any executable code.
fetchUser('alice');

We go deeper into modules in our existing post on JS module systems.

Function-declaration hoisting in blocks (the messy case) #

There is one ugly corner: function declarations inside blocks. Their hoisting behavior depends on whether you are in strict mode (and which version of the spec your runtime targets).

'use strict';
if (true) {
  function greet() { return 'hi'; }
}
greet(); // ReferenceError in strict mode

In non-strict mode, older engines hoisted greet out of the block. In modern strict-mode code (which is what every module and class body is by default), greet is block-scoped just like let.

Rule of thumb: declare functions at the top level of a module or function, not inside if / for / while. Use a function expression assigned to a const if you need a function inside a block.

Common gotchas #

Gotcha 1: Variables declared with var leak to the global object.

var globalLeak = 'oops';
window.globalLeak; // 'oops' — in browsers

This is one reason modern code avoids var entirely. let and const declared at the top level do not attach to window / globalThis.

Gotcha 2: Redeclaring with var is silently allowed; with let/const it throws.

var x = 1; var x = 2; // fine
let  y = 1; let  y = 2; // SyntaxError

Gotcha 3: typeof does NOT save you from the TDZ.

console.log(typeof noDecl);  // 'undefined' (safe — variable was never declared)
console.log(typeof letVar);  // ReferenceError
let letVar;

With truly-undeclared variables, typeof returns 'undefined' without throwing. But inside the TDZ, even typeof throws. This is the one place TDZ is more aggressive than people expect.

Gotcha 4: const does not mean immutable.

const obj = { count: 0 };
obj.count = 1; // fine — the BINDING is constant, not the value
obj = {};      // TypeError — re-binding is not allowed

For true immutability, see our post on mutability and immutability.

Modern best practice #

In 2026, the rules are simple:

  • Default to const. Always.
  • Use let only when you genuinely need to reassign. This is rarer than you think.
  • Never use var — block scope and TDZ catch real bugs.
  • Declare functions at the top of their enclosing scope (or, for callbacks, assign a function expression to a const).

Linters (eslint-config-airbnb, eslint-config-standard, the Google style guide) enforce these. Most modern codebases have not contained a var in years.

Recap #

  • Hoisting is the memory-allocation step that runs before your code executes — nothing physically moves.
  • var and function declarations are declared AND initialized at creation. They are usable from line 1.
  • let, const, and class are declared but not initialized — accessing them throws until you hit their declaration line. This is the temporal dead zone.
  • var is function-scoped; let / const are block-scoped. This is the difference behind the classic setTimeout in a for loop bug.
  • Default to const, use let when reassignment is genuinely needed, never use var.

Next up: Lexical Scope — how JavaScript resolves variable lookups when names overlap across nested scopes, and why "where it is written" matters more than "where it is called."

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *