ESC

Type to search the knowledge base.

Hoisting in JavaScript

What the engine does before your code runs — var vs function vs let/const, the TDZ, and the bugs that look like magic until you name the phase.

beginner6 min read
  • javascript
  • hoisting
  • scope
  • var
  • let
  • const

You write code top-to-bottom. The JS engine does not evaluate it that way on day one of a scope. Before a line runs, the runtime scans the scope for declarations and wires up bindings. People call that hoisting. The word is a teaching shortcut, not a magical “move everything to the top” rewrite of your file.

If you only remember “var is undefined, functions work early,” you’ll still trip on let, class, and default parameters. The useful model is: create bindings first, assign later — and how creation initializes them differs by declaration kind.

The problem hoisting explains

This looks broken until you know the rules:

console.log(x); // undefined — not ReferenceError
var x = 10;
console.log(x); // 10

And this looks fine until it isn’t:

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

Same “use before line that writes the value” shape. Different binding lifecycle. Interviews and production bugs both live in that gap.

Model: two moments per binding

For every scope (global, function, module, or block for let/const):

  1. Instantiation — create the binding (and maybe initialize it).
  2. Evaluation — run statements; assignments happen here.
Declaration Scope Instantiation value Access before assignment line
var function / global undefined OK, value is undefined
function declaration function / global (block rules vary) the function object OK, fully usable
let / const block uninitialized (TDZ) ReferenceError
class block uninitialized (TDZ) ReferenceError

“Hoisted” for var means the binding exists for the whole function. It does not mean the right-hand side ran early.

function demo() {
  console.log(a); // undefined
  var a = 1;
  console.log(a); // 1
}
// Roughly like:
// function demo() {
//   var a;          // created, undefined
//   console.log(a);
//   a = 1;
//   console.log(a);
// }

Function declarations vs expressions

Function declarations are initialized with the function body during instantiation of the enclosing scope:

// Works: declaration is fully available in the scope
console.log(add(2, 3)); // 5

function add(a, b) {
  return a + b;
}

Function expressions only create a value when evaluation reaches the assignment:

console.log(typeof sub); // "undefined" with var
// console.log(sub(5, 1)); // TypeError: sub is not a function

var sub = function (a, b) {
  return a - b;
};

// With let/const, even `typeof` of the name can hit TDZ depending on access form:
// console.log(mul); // ReferenceError
const mul = (a, b) => a * b;

Named function expressions hoist only the outer binding you assigned to — not a free-floating name for the whole scope the way a declaration does.

const fact = function factorial(n) {
  return n <= 1 ? 1 : n * factorial(n - 1); // internal name works
};
// factorial is not in outer scope

Duplicate var and function merging

var and function declarations in the same function scope can collide. Engines fold them with specific priority rules; the readable takeaway is: don’t dual-declare the same name. Prefer let/const and one declaration style.

function messy() {
  console.log(typeof f); // "function" in most engines — declaration wins over var init
  var f = 1;
  function f() {}
  console.log(typeof f); // "number" after assignment
}

If you need to reason about this under pressure, rewrite to one binding. Interviewers care that you know collisions exist, not that you recite ES5 Annex B.

Blocks, var leaks, and let loops

var ignores block braces for scope. Only functions (and modules) fence it:

function f() {
  if (true) {
    var leaked = 'still function-scoped';
  }
  console.log(leaked); // works
}

let/const are block-scoped — including for loops, which create a new binding per iteration for let. That interaction is why closures in loops stop returning the final index when you switch from var to let. Full story: Closures and Temporal Dead Zone.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log('var', i), 0);
}
// var 3, var 3, var 3

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log('let', j), 0);
}
// let 0, let 1, let 2

Classes and the TDZ

Classes hoist the binding into the TDZ like let. You cannot new them above the declaration:

// new Box(); // ReferenceError
class Box {
  constructor(v) {
    this.v = v;
  }
}

Same idea for const/let used in temporal dependency between declarations in one block.

Default parameters and “temporal” neighbors

Default parameter initializers sit in a special zone relative to the parameter list:

function weird(a = b, b = 2) {
  // ReferenceError if called as weird() — `b` is in TDZ when evaluating default for `a`
  return [a, b];
}

function ok(a = 1, b = a) {
  return [a, b]; // weird(undefined) → [1, 1] once fixed order
}

Order of parameters matters. Treat defaults as sequential let-like bindings.

What hoisting is not

  • Not “the engine rewrites your file so assignments move up.”
  • Not a reason to call functions before defining them as a style preference.
  • Not the same for modules vs scripts in every edge case (import bindings are live and also in a TDZ until linked).
  • Not an excuse for var in new code — use const/let and put declarations where readers expect them.

Readable code still declares before use. Hoisting knowledge is for debugging, interview precision, and understanding legacy var.

Footguns that show up in real reviews

  1. Assuming typeof x is always safe — for var, yes ("undefined"). For let/const in TDZ, accessing the binding throws.
  2. Conditional function declarations — behavior historically differed across browsers in sloppy scripts. Prefer function expressions or always-declared functions.
  3. Relying on hoisted helpers deep in a file — works for declarations, confuses readers and breaks when someone converts to const fn = () => {}.
  4. Confusing TDZ with “not hoisted” — let is hoisted (binding exists); it is not initialized. Say that out loud in interviews.

Interview angle

Prompt: “What is hoisting?”

Strong answer: “During scope instantiation, the engine creates bindings for declarations before evaluating statements. var is initialized to undefined, function declarations are initialized with the function object, and let/const/class stay uninitialized until their declaration line — accessing them early throws (TDZ). Hoisting is about bindings, not moving assignments.”

Follow-ups:

  • Difference between function declaration and const f = function(){}
  • Why var in a loop + async callback shares one binding
  • How this binding is unrelated to hoisting (call-site vs scope creation)

Whiteboard mini-task: predict output of mixed var / function declaration / let in one function without running it.

Further reading

Related guides