JavaScript · Intermediate

Scope and Hoisting in JavaScript

10 min readUpdated September 24, 2026Every example verified

In short: Scope is the region of code where a name is visible: JavaScript has global scope, function scope and, since ES2015, block scope for let, const and class. Hoisting means declarations are processed before the code runs: a var exists from the top of its function as undefined, a function declaration is callable before its line, and let/const cannot be touched until their line runs.

Where a name lives and when it comes to life

Every variable and function is declared somewhere, and that place decides where it can be used. A name declared at the top level of a script is global and visible everywhere. A name declared inside a function is visible only inside that function, including any functions nested within it. A name declared with let, const or class inside a pair of braces, whether an if, a loop or a bare { } block, is visible only inside those braces. Scopes nest: code looks for a name in its own scope first, then in the enclosing scope, and so on outwards to the global scope. Because that search follows the structure of the source text rather than the order of calls, JavaScript scoping is called lexical.

var is the exception that makes the topic worth a lesson. A var ignores blocks and belongs to the nearest enclosing function (or the global scope if there is none). A var declared inside an if is still there after the if, and the counter of for (var i = ...) is still there after the loop. let and const, introduced in ES2015, respect blocks, which is why modern code uses them and reserves var for old scripts.

Before running a scope, the engine registers every declaration in it. This is hoisting, and it behaves differently per kind of declaration. A var is created at the top of its function with the value undefined, so reading it before its line gives undefined rather than an error. A function declaration is hoisted together with its body, so it can be called from a line above it, which lets you put helper functions at the bottom of a file. A function expression assigned to a variable is not: the variable hoists, the function does not, so calling it early throws a TypeError because you are calling undefined.

let, const and class are also registered before the code runs, but they are placed in the temporal dead zone: from the start of the block until the declaration line executes, any read or write throws a ReferenceError. This is deliberate. A var read too early silently produces undefined and the bug surfaces somewhere else; a let read too early fails at the exact line where the mistake is.

An inner scope may declare a name that also exists outside. The inner one shadows the outer for as long as the inner scope lasts, and the outer value is untouched. Shadowing is legal but easy to misread, so avoid reusing a name for a different purpose within one function.

Assigning to a name that was never declared is a trap in old-style scripts: it silently creates a global variable. In strict mode, which is on by default in modules and classes and which this site's runner uses, the same assignment throws a ReferenceError. Declare every variable, and prefer const unless you need to reassign.

Syntax

 JavaScript · syntax
// function scope: var belongs to the whole function
function f() {
  if (true) { var a = 1; }
  console.log(a);      // 1: the block did not contain it
}

// block scope: let and const end at the closing brace
if (true) { let b = 2; const c = 3; }
// b and c do not exist here

// hoisting
hello();               // works: function declarations hoist with their body
function hello() {}
console.log(v);        // undefined: var hoists without its value
var v = 5;
console.log(w);        // ReferenceError: w is in the temporal dead zone
let w = 6;

A function declaration must be a statement on its own. When a function is stored in a variable with const g = function () {} or an arrow function, it follows the rules of that variable, not of function declarations.

Function scope versus block scope

Three variables are declared inside an if block with var, let and const. Watch which ones are still visible after the block, and what happens to a loop counter.

 JavaScript
function countStock() {
  if (true) {
    var boxes = 12;
    let crates = 3;
    const pallets = 1;
    console.log("inside:", boxes, crates, pallets);
  }
  console.log("var after the block:", boxes);
  console.log("let after the block:", typeof crates);
  console.log("const after the block:", typeof pallets);
}
countStock();

for (var i = 0; i < 3; i++) {}
console.log("i after the loop:", i);

for (let j = 0; j < 3; j++) {}
console.log("j after the loop:", typeof j);

Output

inside: 12 3 1
var after the block: 12
let after the block: undefined
const after the block: undefined
i after the loop: 3
j after the loop: undefined

boxes was declared with var, so it belongs to the whole countStock function and is readable after the if. crates and pallets ended with the block; typeof on a name that does not exist returns the string "undefined" without throwing, which is the one safe way to probe for an undeclared name. The var i loop counter leaks out with its final value 3, while let j is gone as soon as the loop ends.

Hoisting: what you can use before its line

The function greet is called before it is defined, a var is read before it is assigned, and a let and a function expression are read too early inside try blocks so the errors can be shown.

 JavaScript
console.log(greet("Sami"));

function greet(name) {
  return "Welcome, " + name;
}

console.log("before var:", price);
var price = 40;
console.log("after var:", price);

try {
  console.log(discount);
} catch (err) {
  console.log("let before declaration:", err.name);
}
let discount = 5;
console.log("after let:", discount);

try {
  console.log(shout("hi"));
} catch (err) {
  console.log("function expression before assignment:", err.name);
}
var shout = function (text) { return text.toUpperCase(); };
console.log(shout("hi"));

Output

Welcome, Sami
before var: undefined
after var: 40
let before declaration: ReferenceError
after let: 5
function expression before assignment: TypeError
HI

greet works on line one because a function declaration is hoisted with its body. price exists from the start but holds undefined until its line runs. discount is in the temporal dead zone, so reading it throws a ReferenceError, caught here only to print its name. shout shows the difference between the two ways of defining a function: the var is hoisted as undefined, and calling undefined is a TypeError, not a ReferenceError.

Lexical scope, shadowing and undeclared names

An inner function reads variables from two enclosing scopes, a block shadows an outer variable, and a function assigns to a name that was never declared.

 JavaScript
const unit = "kg";

function makeLabel(weight) {
  const prefix = "net";
  function format() {
    return `${prefix} ${weight} ${unit}`;
  }
  return format();
}
console.log(makeLabel(2.5));

let level = "outer";
{
  let level = "block";
  console.log(level);
}
console.log(level);

function setTotal() {
  total = 10;
}
try {
  setTotal();
} catch (err) {
  console.log(err.name + ": assigning to an undeclared name");
}

Output

net 2.5 kg
block
outer
ReferenceError: assigning to an undeclared name

format uses prefix from makeLabel, weight from the parameter list and unit from the global scope: the lookup walks outwards through the scopes that enclose it in the source. The inner let level is a separate variable that hides the outer one only inside the braces; the outer level is still "outer" afterwards. total = 10 has no declaration anywhere. This program runs in strict mode, so the assignment throws instead of quietly creating a global.

var, let and const compared

Behaviourvarletconst
Scopefunctionblockblock
Read before the declaration lineundefinedReferenceError (TDZ)ReferenceError (TDZ)
Redeclare in the same scopeallowedSyntaxErrorSyntaxError
Reassignyesyesno (the binding is fixed; object contents can still change)
Loop counter per iterationone shared variablea fresh binding each iterationcannot be incremented (fine in for...of)
Introducedoriginal languageES2015ES2015

Common mistakes

  • Reading a var before it is assigned and getting undefined

    Why it goes wrong: Hoisting makes the name exist from the top of the function, so there is no error, just undefined flowing into the next calculation and failing later as NaN or "undefined" in a string.

    Fix: Declare with let or const at the point of first use. A too-early read then fails immediately with a ReferenceError that names the variable.

  • Expecting an if or for block to contain a var

    Why it goes wrong: var belongs to the function, so two blocks that both declare var temp share one variable and overwrite each other, and a loop counter is still alive after the loop.

    Fix: Use let or const in blocks; each block then gets its own variables.

     JavaScript · fix
    for (let i = 0; i < 3; i++) { /* i is private to the loop */ }
  • Assigning to a name without declaring it

    Why it goes wrong: In non-strict scripts count = 1 creates a global variable, so a typo such as cuont = 1 silently makes a second variable instead of updating the first. In strict mode it throws a ReferenceError.

    Fix: Always declare with const or let, and use strict mode (modules and classes are strict automatically).

  • Calling a function expression above its definition

    Why it goes wrong: Only function declarations hoist with their body. const f = () => {} follows const rules, so an earlier call hits the temporal dead zone; with var it calls undefined and throws a TypeError.

    Fix: Define function expressions before you use them, or write helpers that must be callable early as function declarations.

Where you use this

Scope is what makes a helper variable safe. A const total inside a function cannot collide with a total in another function, so you can name things naturally without a global registry of names. Block scope goes further: a temporary inside an if or a loop body vanishes at the closing brace, which keeps the surrounding function tidy and stops a stale value from being read by mistake.

Hoisting of function declarations shapes how files are laid out. Many codebases put the main flow at the top of a file and the helper functions below it, which reads like a summary followed by details; that only works because declarations are usable before their line. Understanding the temporal dead zone also explains the ReferenceError you get when two const initialisers accidentally refer to each other, and the next lesson, closures, builds directly on lexical scope: a function remembers the scope it was created in.

 JavaScript · in practice
function main() {
  const rows = parse(readAll());
  console.log(summarise(rows));
}
main();

function parse(text) { /* ... */ }
function summarise(rows) { /* ... */ }

Key points

  • Scope is lexical: a name is looked up in the current scope, then in each enclosing scope out to the global one.
  • var is function-scoped and ignores blocks; let, const and class are block-scoped.
  • var hoists as undefined; function declarations hoist with their body; function expressions follow the rules of the variable they are stored in.
  • let and const are in the temporal dead zone until their line runs, so an early access throws a ReferenceError.
  • An inner declaration with the same name shadows the outer one without changing it.
  • In strict mode, assigning to an undeclared name is a ReferenceError; in sloppy scripts it creates a global.
  • typeof name is the one expression that is safe on an undeclared name.

Try it yourself

Both loops declare their counter with var, so they share a single variable i and the outer loop runs only once. Change the declarations so each loop has its own block-scoped counter. The program should print inner 0, 1, 2 followed by outer 0, then the same again with outer 1.

Your program
for (var i = 0; i < 2; i++) {
  for (var i = 0; i < 3; i++) {
    console.log("inner", i);
  }
  console.log("outer", i);
}
Expected output: inner 0 inner 1 inner 2 outer 0 inner 0 inner 1 inner 2 outer 1

Practise this

Exercises for this lesson are in the JavaScript practice set.

Open the JavaScript playground

Frequently asked questions

Are let and const hoisted in JavaScript?

Yes, but not the way var is. The engine registers let and const names before running the block, which is why an inner declaration shadows an outer variable for the whole block, not just from its line onwards. Until the declaration line executes the variable is in the temporal dead zone and any access throws a ReferenceError, so in practice you cannot use it early.

What is the temporal dead zone?

The stretch of code from the start of a block to the line where a let, const or class declaration runs. The name exists during that stretch but is uninitialised, and reading or assigning it throws a ReferenceError. It exists so that using a variable before its declaration is a visible error rather than a silent undefined.

Does a top-level var become a property of the global object?

In a classic browser script, yes: var count = 1 at the top level creates window.count, while top-level let and const do not. In an ES module, and in a Node.js CommonJS file, top-level declarations are scoped to the module and do not touch the global object. This is one more reason to prefer let and const.

Progress is stored only in this browser.

How this page was checked. Every program on it was run with Node.js 22 at build time; runs in your browser in an isolated Web Worker by the publishing checks, and the output shown is what it printed. The Run buttons execute the same code on your device; nothing you type is sent anywhere.