JavaScriptbeginner

JavaScript Scope & Hoisting — The Complete Guide

Master JavaScript scope and hoisting: global scope, function scope, block scope, var/let/const differences, temporal dead zone, and common mistakes developers make.

15 min read·Published Mar 10, 2026
scopehoistingvariablesjavascript

What Is Scope?

Scope determines where variables are accessible in your code. When you declare a variable, scope decides which parts of your program can see and use it.

function greet() {
  const message = 'Hello'; // message exists only inside greet()
  console.log(message);    // works
}

greet();
console.log(message); // ReferenceError: message is not defined

JavaScript has three types of scope:

Scope Type       Created By                  Accessible Where
──────────────────────────────────────────────────────────────────
Global           top-level code              everywhere
Function         function declaration/expr   inside the function
Block            { } with let/const          inside the block

Global Scope

Variables declared outside any function or block exist in the global scope. They are accessible from anywhere in your program.

// Global scope
const appName = 'MyApp';
let userCount = 0;

function showInfo() {
  // Can access global variables
  console.log(appName);  // 'MyApp'
  console.log(userCount); // 0
}

if (true) {
  // Can access global variables
  console.log(appName); // 'MyApp'
}

Global Variables and the Global Object

In browsers, var declarations and function declarations at the top level become properties of the window object. let and const do not.

var globalVar = 'I am on window';
let globalLet = 'I am NOT on window';
const globalConst = 'I am NOT on window';

console.log(window.globalVar);   // 'I am on window'
console.log(window.globalLet);   // undefined
console.log(window.globalConst); // undefined

This is one of many reasons to avoid var — it pollutes the global object.

Why Global Variables Are Dangerous

// File: auth.js
var user = 'admin';

// File: analytics.js (loaded later)
var user = 'anonymous'; // silently overwrites auth.js's user variable

// File: auth.js (checks user later)
console.log(user); // 'anonymous' — BUG

Global variables create naming collisions, make code hard to reason about, and cannot be garbage collected. Minimize them.

Function Scope

Variables declared with var, let, or const inside a function are scoped to that function. They cannot be accessed from outside.

function calculateTax(income) {
  const taxRate = 0.3;          // function-scoped
  var taxAmount = income * taxRate; // function-scoped
  let result = income - taxAmount;  // function-scoped

  return result;
}

calculateTax(100000);
// console.log(taxRate);   // ReferenceError
// console.log(taxAmount); // ReferenceError
// console.log(result);    // ReferenceError

Nested Functions and Scope Chain

Functions can access variables from their parent scope. This is called the scope chain.

const global = 'I am global';

function outer() {
  const outerVar = 'I am outer';

  function middle() {
    const middleVar = 'I am middle';

    function inner() {
      const innerVar = 'I am inner';

      // inner can access everything above it
      console.log(innerVar);  // I am inner
      console.log(middleVar); // I am middle
      console.log(outerVar);  // I am outer
      console.log(global);    // I am global
    }

    inner();
    // console.log(innerVar); // ReferenceError
  }

  middle();
  // console.log(middleVar); // ReferenceError
}

The lookup works from inside out:

inner() scope
  │ not found? look up
  v
middle() scope
  │ not found? look up
  v
outer() scope
  │ not found? look up
  v
Global scope
  │ not found?
  v
ReferenceError

Block Scope

let and const are block-scoped. A block is any code wrapped in curly braces {} — if/else, for, while, or even a standalone block.

if (true) {
  let blockLet = 'visible only in this block';
  const blockConst = 'also only in this block';
  var blockVar = 'visible outside the block!';
}

// console.log(blockLet);   // ReferenceError
// console.log(blockConst); // ReferenceError
console.log(blockVar);      // 'visible outside the block!'

Block Scope in Loops

This is where block scope makes the biggest practical difference.

for (let i = 0; i < 3; i++) {
  // Each iteration gets its own i
  setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2

for (var i = 0; i < 3; i++) {
  // All iterations share the same i
  setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3

With let, each iteration of the loop creates a new binding. With var, there is only one i shared across all iterations.

let in a loop:

  Iteration 0:  let i = 0  ─> callback captures i=0
  Iteration 1:  let i = 1  ─> callback captures i=1
  Iteration 2:  let i = 2  ─> callback captures i=2

var in a loop:

  var i (one variable for all iterations)
  Iteration 0:  i = 0  ─> callback captures reference to i
  Iteration 1:  i = 1  ─> callback captures reference to i
  Iteration 2:  i = 2  ─> callback captures reference to i
  Loop ends:    i = 3  ─> all callbacks see i = 3

Standalone Blocks

You can use a standalone block to create a limited scope.

{
  const temp = computeExpensiveValue();
  processValue(temp);
}
// temp is gone — cannot be accessed here
// clean scope, no lingering variables

var vs let vs const

Here is a comprehensive comparison:

Feature              var              let              const
──────────────────────────────────────────────────────────────────
Scope                function         block            block
Hoisting             yes (undefined)  yes (TDZ)        yes (TDZ)
Re-declaration       allowed          not allowed      not allowed
Re-assignment        allowed          allowed          not allowed
Global object prop   yes              no               no
Loop binding         shared           per-iteration    per-iteration

var — The Legacy Declaration

// var is function-scoped, not block-scoped
function example() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10 (still accessible — var ignores blocks)
}

// var allows re-declaration
var name = 'Alice';
var name = 'Bob'; // no error — silently overwrites
console.log(name); // 'Bob'

// var is hoisted with value undefined
console.log(y); // undefined (not ReferenceError)
var y = 5;

let — The Modern Variable

// let is block-scoped
function example() {
  if (true) {
    let x = 10;
  }
  // console.log(x); // ReferenceError
}

// let does not allow re-declaration in same scope
let name = 'Alice';
// let name = 'Bob'; // SyntaxError: Identifier 'name' has already been declared

// let allows re-assignment
let count = 0;
count = 1; // fine

// let is hoisted but not initialized (TDZ)
// console.log(z); // ReferenceError: Cannot access 'z' before initialization
let z = 5;

const — The Constant Binding

// const must be initialized at declaration
const PI = 3.14159;
// const UNSET; // SyntaxError: Missing initializer in const declaration

// const cannot be re-assigned
// PI = 3.14; // TypeError: Assignment to constant variable

// BUT: const objects/arrays CAN be mutated
const user = { name: 'Alice' };
user.name = 'Bob'; // fine — mutating the object, not re-assigning the binding
user.age = 30;     // fine

const items = [1, 2, 3];
items.push(4);     // fine — [1, 2, 3, 4]
// items = [5, 6]; // TypeError — re-assignment not allowed

// To prevent mutation, use Object.freeze
const frozen = Object.freeze({ name: 'Alice' });
frozen.name = 'Bob'; // silently fails (or throws in strict mode)
console.log(frozen.name); // 'Alice'

Which Should You Use?

Guideline                               Use
──────────────────────────────────────────────
Value never changes                     const
Value needs re-assignment               let
Legacy code / specific reason           var (avoid)

Default to const. Use let when you need to re-assign. Avoid var entirely in new code.

Hoisting

Hoisting is JavaScript's behavior of moving declarations to the top of their scope during the compilation phase. The declarations are moved, but the assignments stay in place.

var Hoisting

var declarations are hoisted and initialized to undefined.

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

The engine processes this as:

// What JavaScript actually does:
var x;           // declaration hoisted to top, initialized to undefined
console.log(x);  // undefined
x = 5;           // assignment stays in place
console.log(x);  // 5

A more complex example:

function init() {
  console.log(status); // undefined
  console.log(count);  // undefined

  var status = 'ready';
  var count = 0;

  console.log(status); // 'ready'
  console.log(count);  // 0
}

Processed as:

function init() {
  var status; // hoisted
  var count;  // hoisted

  console.log(status); // undefined
  console.log(count);  // undefined

  status = 'ready'; // assignment in place
  count = 0;        // assignment in place

  console.log(status); // 'ready'
  console.log(count);  // 0
}

let and const Hoisting (Temporal Dead Zone)

let and const are also hoisted, but they are not initialized. Accessing them before the declaration throws a ReferenceError. The region between the start of the scope and the declaration is called the Temporal Dead Zone (TDZ).

{
  // TDZ starts for x
  // console.log(x); // ReferenceError: Cannot access 'x' before initialization
  // TDZ continues...
  // console.log(x); // still ReferenceError
  let x = 10; // TDZ ends, x is now initialized
  console.log(x); // 10
}

Visualized:

{
  ┌─── TDZ for x ───┐
  │                  │    x exists but cannot be accessed
  │  console.log(x)  │ -> ReferenceError
  │                  │
  └──────────────────┘
  let x = 10;             x is initialized here
  console.log(x);         works fine — 10
}

TDZ Is Temporal, Not Spatial

The TDZ is based on execution order, not position in code.

function logValue() {
  console.log(value); // no error when function is DEFINED
}

let value = 42;
logValue(); // 42 — works because value is initialized when function is CALLED

// But this throws:
function broken() {
  console.log(x);
  let x = 10; // x's TDZ covers the console.log line
}
broken(); // ReferenceError

Function Hoisting

Function declarations are fully hoisted — both the name and the body.

// Works — function declarations are fully hoisted
greet(); // 'Hello!'

function greet() {
  console.log('Hello!');
}

Function expressions follow the rules of their variable keyword.

// var function expression — hoisted as undefined
// sayHi(); // TypeError: sayHi is not a function (undefined is not callable)
var sayHi = function () {
  console.log('Hi!');
};

// let/const function expression — TDZ applies
// sayBye(); // ReferenceError: Cannot access 'sayBye' before initialization
const sayBye = function () {
  console.log('Bye!');
};

// Arrow functions follow the same rules
// add(1, 2); // ReferenceError
const add = (a, b) => a + b;

Function vs var Hoisting Priority

When a function declaration and a var declaration have the same name, the function declaration wins.

console.log(typeof foo); // 'function' (not 'undefined')

var foo = 'string';
function foo() {
  return 'function';
}

console.log(typeof foo); // 'string' (var assignment runs after)

The engine processes this as:

// Hoisting phase:
function foo() { return 'function'; } // function hoisted first
var foo; // var re-declaration ignored (function already defined)

// Execution phase:
console.log(typeof foo); // 'function'
foo = 'string';          // assignment overwrites
console.log(typeof foo); // 'string'

Scope in Practice

if/else and Scope

const score = 85;

if (score >= 90) {
  const grade = 'A';
  var passed = true;
} else if (score >= 80) {
  const grade = 'B'; // different block, different variable
  var passed = true;  // same var — function/global scope
} else {
  const grade = 'F';
  var passed = false;
}

// console.log(grade);  // ReferenceError — block-scoped
console.log(passed);    // true — var leaks out of block

switch and Scope

Switch cases share the same block scope by default. Use explicit blocks to isolate them.

// BUG: shared scope
switch (action) {
  case 'create':
    let result = createItem(); // declared in switch block
    break;
  case 'delete':
    let result = deleteItem(); // SyntaxError: already declared
    break;
}

// FIX: use blocks
switch (action) {
  case 'create': {
    let result = createItem(); // scoped to this block
    break;
  }
  case 'delete': {
    let result = deleteItem(); // different block, no conflict
    break;
  }
}

for...in and for...of

const obj = { a: 1, b: 2, c: 3 };

for (const key in obj) {
  console.log(key); // 'a', 'b', 'c'
}
// console.log(key); // ReferenceError (const is block-scoped)

const arr = [10, 20, 30];

for (const value of arr) {
  console.log(value); // 10, 20, 30
}
// console.log(value); // ReferenceError

Closures and Scope

Closures are a direct consequence of scope rules. A function retains access to its outer scope even after the outer function returns.

function createMultiplier(factor) {
  // factor is in createMultiplier's scope
  return function (number) {
    return number * factor; // inner function closes over factor
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5));  // 10 — factor is 2
console.log(triple(5));  // 15 — factor is 3

Each call to createMultiplier creates a new scope with its own factor. The returned function remembers that scope. (See the closures article for a deep dive.)

Shadowing

When a variable in an inner scope has the same name as one in an outer scope, the inner variable shadows the outer one.

const x = 'global';

function test() {
  const x = 'function'; // shadows global x
  console.log(x);       // 'function'

  if (true) {
    const x = 'block';  // shadows function x
    console.log(x);     // 'block'
  }

  console.log(x);       // 'function' (block x is gone)
}

test();
console.log(x);          // 'global' (never modified)

Illegal Shadowing

You cannot shadow a let with a var in the same function.

function test() {
  let x = 10;
  if (true) {
    // var x = 20; // SyntaxError: Identifier 'x' has already been declared
    let x = 20;    // fine — block scope shadows
    console.log(x); // 20
  }
}

But you CAN shadow a var with a let:

function test() {
  var x = 10;
  if (true) {
    let x = 20; // fine — let creates a new block-scoped binding
    console.log(x); // 20
  }
  console.log(x); // 10
}

Common Mistakes

Mistake 1: Accidental Global Variables

function processData() {
  // BUG: missing declaration keyword — creates global variable
  result = 42;
}

processData();
console.log(result); // 42 — leaked to global scope

// FIX: always use const/let
function processData() {
  const result = 42;
}

In strict mode, assigning to an undeclared variable throws a ReferenceError.

'use strict';

function processData() {
  result = 42; // ReferenceError: result is not defined
}

Mistake 2: var in Loops

// BUG: all buttons show the same index
for (var i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function () {
    console.log(`Button ${i} clicked`); // always buttons.length
  });
}

// FIX: use let
for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', function () {
    console.log(`Button ${i} clicked`); // correct index
  });
}

Mistake 3: Hoisting Surprises

// BUG: developer expects x to be global value
var x = 'global';

function surprise() {
  console.log(x); // undefined (not 'global'!)
  var x = 'local';
  console.log(x); // 'local'
}

surprise();

The var x inside surprise() is hoisted to the top of the function, shadowing the global x. The first console.log sees the hoisted (but uninitialized) local x.

// What the engine sees:
function surprise() {
  var x;            // hoisted — shadows global x
  console.log(x);   // undefined
  x = 'local';
  console.log(x);   // 'local'
}

Mistake 4: Assuming const Means Immutable

const config = {
  debug: false,
  version: '1.0',
};

// This works — you are mutating the object, not the binding
config.debug = true;
config.newProp = 'added';

// This throws — you are trying to re-assign the binding
// config = {}; // TypeError

// For true immutability, use Object.freeze
const frozenConfig = Object.freeze({
  debug: false,
  version: '1.0',
});

frozenConfig.debug = true; // silently fails
console.log(frozenConfig.debug); // false

Module Scope

ES modules create their own scope. Variables declared at the top level of a module are not global — they are module-scoped.

// module-a.js
const secret = 'only visible in this module';
export const publicValue = 'visible to importers';

// module-b.js
import { publicValue } from './module-a.js';
console.log(publicValue); // 'visible to importers'
// console.log(secret);   // ReferenceError — not exported

This is one of the biggest advantages of modules over scripts. Each module has its own scope, so naming collisions between files become impossible.

Scope Summary Diagram

┌──────────────────────────────────────────────────────┐
│  GLOBAL SCOPE                                         │
│  var globalVar    (on window in browser)              │
│  let globalLet    (not on window)                     │
│  const globalConst                                    │
│                                                       │
│  ┌─────────────────────────────────────────────┐     │
│  │  FUNCTION SCOPE                              │     │
│  │  var, let, const — all function-scoped       │     │
│  │                                              │     │
│  │  ┌────────────────────────────────────┐     │     │
│  │  │  BLOCK SCOPE (if, for, {})         │     │     │
│  │  │  let, const — block-scoped         │     │     │
│  │  │  var — leaks to function scope     │     │     │
│  │  └────────────────────────────────────┘     │     │
│  └─────────────────────────────────────────────┘     │
└──────────────────────────────────────────────────────┘

Key Takeaways

  • JavaScript has three scope levels: global, function, and block
  • var is function-scoped; let and const are block-scoped
  • Default to const, use let when re-assignment is needed, avoid var
  • var is hoisted and initialized to undefined; let and const are hoisted but enter the Temporal Dead Zone
  • Function declarations are fully hoisted (name + body); function expressions follow their variable's rules
  • The scope chain looks up from inner scopes to outer scopes until it finds the variable or reaches global
  • Shadowing lets inner scopes override outer variable names without affecting the outer variable
  • Accidental globals are created when you assign to undeclared variables — use strict mode to catch them
  • ES modules create their own scope — prefer modules over scripts to avoid global pollution
  • Understanding scope is the foundation for understanding closures, this binding, and memory management

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles