Home JavaScript JavaScript Scope and Hoisting Explained — How JS Really Reads Your Code

JavaScript Scope and Hoisting Explained — How JS Really Reads Your Code

In Plain English 🔥
Imagine your house has rooms. You can grab anything sitting in the living room from anywhere in the house, but stuff locked inside your bedroom stays private to that room. Scope works the same way — it decides which 'rooms' in your code can see which variables. Hoisting is like a helpful (but sometimes confusing) assistant who runs through the house before the party starts and writes all the room labels on a whiteboard — but doesn't always fill in the contents yet. That gap between the label being written and the contents arriving? That's where most JavaScript bugs are born.
⚡ Quick Answer
Imagine your house has rooms. You can grab anything sitting in the living room from anywhere in the house, but stuff locked inside your bedroom stays private to that room. Scope works the same way — it decides which 'rooms' in your code can see which variables. Hoisting is like a helpful (but sometimes confusing) assistant who runs through the house before the party starts and writes all the room labels on a whiteboard — but doesn't always fill in the contents yet. That gap between the label being written and the contents arriving? That's where most JavaScript bugs are born.

Every JavaScript bug involving 'undefined', a ReferenceError, or a variable that seems to exist before you created it traces back to the same two concepts: scope and hoisting. These aren't academic curiosities — they determine whether your event handlers share state they shouldn't, whether a refactor quietly breaks something three files away, and whether that loop you wrote actually captures the value you think it does. Senior developers don't just avoid these bugs by instinct; they avoid them because they understand the engine's rulebook.

Scope answers the question: 'Who is allowed to see this variable?' Hoisting answers: 'When does the engine become aware of this variable?' JavaScript's answers to both questions are surprising if you learned another language first, and they're doubly surprising because var, let, const, and function declarations all play by slightly different rules. Understanding those differences isn't about memorising edge cases — it's about building a mental model of how the JavaScript engine actually processes your file before running a single line.

By the end of this article you'll be able to predict — not guess — what any piece of JavaScript code will print when variables are declared in unexpected places, explain the temporal dead zone in a way that makes interviewers nod, write loop closures that capture the right value every time, and confidently choose between var, let, and const based on the actual behaviour you need rather than cargo-cult rules.

How Scope Works — and Why JavaScript Has Three Kinds

Scope is the set of rules that determines where in your code a particular variable is visible and accessible. JavaScript has three scope levels: global, function, and block.

Global scope is the outermost room — anything declared here is visible everywhere in your script. That sounds convenient, but it's a trap in large applications. Two scripts that both declare a global variable called 'data' will silently overwrite each other. This is why module systems exist.

Function scope means a variable declared inside a function is invisible outside it. Every function call creates a brand-new scope. This is the original scope boundary JavaScript shipped with and it's why var was designed to be function-scoped — not block-scoped.

Block scope, introduced with ES6's let and const, makes a variable visible only within the nearest pair of curly braces — an if block, a for loop, or a standalone block. This is the behaviour developers from C, Java, or Python expect by default, and it's far safer for everyday use.

The reason JavaScript has all three isn't historical accident — it's because functions are first-class values in JS. Function scope lets you create private state via closures. Block scope lets you write predictable, leak-free loops. Global scope exists because scripts need a shared communication channel. Each level solves a real problem.

ScopeDemo.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930
// --- GLOBAL SCOPE ---
const appName = 'TheCodeForge'; // visible everywhere in this file

function printWelcome() {
  // --- FUNCTION SCOPE ---
  // 'greeting' is born here and dies when printWelcome() finishes
  const greeting = `Welcome to ${appName}`; // can reach UP to global scope
  console.log(greeting);
}

printWelcome();
// console.log(greeting); // ❌ ReferenceError — greeting doesn't exist out here

// --- BLOCK SCOPE ---
const scores = [42, 87, 63];

for (let i = 0; i < scores.length; i++) {
  // 'i' and 'currentScore' only exist inside this loop block
  const currentScore = scores[i];
  console.log(`Score ${i + 1}: ${currentScore}`);
}

// console.log(i);            // ❌ ReferenceError — i is block-scoped
// console.log(currentScore); // ❌ ReferenceError — currentScore is block-scoped

// --- THE CLASSIC var LEAK ---
for (var legacyIndex = 0; legacyIndex < 3; legacyIndex++) {
  // var is NOT block-scoped, it leaks into the surrounding function (or global) scope
}
console.log('legacyIndex after loop:', legacyIndex); // 👀 prints 3, not an error
▶ Output
Welcome to TheCodeForge
Score 1: 42
Score 2: 87
Score 3: 63
legacyIndex after loop: 3
⚠️
Watch Out: var Leaks Out of Blocksvar only respects function boundaries, not curly-brace blocks. A var declared inside an if statement or for loop is visible to the entire enclosing function — or globally if you're at the top level. This is a source of real bugs. Default to let and const; only reach for var if you specifically need function-scoped behaviour (which is rare in modern code).

The Scope Chain — How JavaScript Climbs the Ladder to Find Your Variable

When JavaScript encounters a variable name, it doesn't just check the current scope and give up. It climbs a chain of parent scopes, one level at a time, until it either finds the variable or runs out of scopes and throws a ReferenceError. This chain is called the scope chain, and it's built at the moment a function is defined — not when it's called. That distinction is critical.

Because the scope chain is set at definition time, JavaScript uses what's called lexical scoping (also called static scoping). The word 'lexical' just means 'based on where you wrote the code in the source file'. A function always remembers the scope it was born in, regardless of where or how it's later invoked.

This is the engine behind closures — one of JavaScript's most powerful and misunderstood features. When an inner function references a variable from an outer function, it doesn't copy that variable. It holds a live reference to the outer scope. That's why a closure can still read and modify a variable even after the outer function has finished running.

Understanding the scope chain transforms closures from magic into something predictable. The chain is just a linked list of scope objects, and the engine walks it from the inside out.

ScopeChainAndClosure.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Real-world example: a counter factory using closures
// The inner function 'increment' climbs the scope chain to find 'count'

function createCounter(startValue) {
  // 'count' lives in createCounter's function scope
  let count = startValue;

  // This returned function closes over 'count' — it holds a live reference,
  // not a snapshot. Every call to increment() reads and updates the SAME count.
  return function increment() {
    count += 1;
    return count;
  };
}

const pageViewCounter = createCounter(0);
const articleLikeCounter = createCounter(0);

console.log('Page views:', pageViewCounter()); // 1
console.log('Page views:', pageViewCounter()); // 2
console.log('Article likes:', articleLikeCounter()); // 1 — completely separate count
console.log('Page views:', pageViewCounter()); // 3

// Why does this work after createCounter() has finished?
// Because 'increment' still holds a reference to the scope where 'count' lives.
// The scope chain keeps that scope alive as long as any function references it.

// --- SCOPE CHAIN LOOKUP IN ACTION ---
const platformName = 'TheCodeForge'; // global scope

function outerSection() {
  const sectionTitle = 'JavaScript Basics'; // outerSection's scope

  function innerLesson() {
    const lessonName = 'Scope and Hoisting'; // innerLesson's scope

    // JS looks for each variable starting from innerLesson's scope
    // and climbs up until it finds it
    console.log(`${platformName} > ${sectionTitle} > ${lessonName}`);
    //            ^ found at global  ^ found at outer   ^ found locally
  }

  innerLesson();
}

outerSection();
▶ Output
Page views: 1
Page views: 2
Article likes: 1
Page views: 3
TheCodeForge > JavaScript Basics > Scope and Hoisting
🔥
Interview Gold: Lexical vs Dynamic ScopingJavaScript uses lexical scoping — a function's scope chain is determined by where it was written in the source code, not where it's called from. Some languages (like older Lisp dialects) use dynamic scoping, where the chain is determined by the call stack. If an interviewer asks 'what kind of scoping does JavaScript use?', say lexical (also called static) and explain that it's what makes closures possible and predictable.

Hoisting — What the JavaScript Engine Does Before It Runs Your Code

Before your JavaScript file executes a single line, the engine makes a preparation pass through your code called the creation phase. During this phase it finds all variable and function declarations and registers them in the appropriate scope. This pre-registration is what we call hoisting — it's as if declarations were physically 'hoisted' (lifted) to the top of their scope.

But here's where developers get tripped up: what gets hoisted depends on whether you used var, let, const, or a function declaration — and the rules are different for each.

Function declarations are hoisted completely — both the name and the body. That's why you can call a function before its declaration in your source code and it just works.

var declarations are hoisted, but only the declaration — not the assigned value. The variable exists from the top of its scope but holds the value undefined until the assignment line is reached. This is the source of countless subtle bugs.

let and const are hoisted too — the engine knows they exist — but they're placed in a 'temporal dead zone' (TDZ) from the start of the block until the declaration line. Accessing them before that line throws a ReferenceError, not undefined. This is actually safer behaviour by design.

Understanding hoisting means you can predict your code's behaviour even when declarations are scattered across a file.

HoistingExplained.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ============================================================
// FUNCTION DECLARATIONS — fully hoisted (name + body)
// ============================================================

// Calling the function BEFORE it appears in the source — this works!
console.log('Tax:', calculateTax(200, 0.2));

function calculateTax(amount, rate) {
  // The entire function body is hoisted, so it's available immediately
  return amount * rate;
}

// ============================================================
// var — declaration hoisted, value is NOT
// ============================================================

console.log('userName before assignment:', userName); // undefined — not an error!
// The engine sees: var userName = undefined; at the top of this scope
// The actual string assignment hasn't happened yet at this line

var userName = 'Alice';
console.log('userName after assignment:', userName); // Alice

// ============================================================
// let / const — hoisted but in the Temporal Dead Zone (TDZ)
// ============================================================

// Uncomment the next line to see the TDZ in action:
// console.log(userEmail); // ❌ ReferenceError: Cannot access 'userEmail' before initialization
// The engine knows 'userEmail' exists (it's hoisted) but blocks access until
// the declaration line is reached — this is the Temporal Dead Zone

let userEmail = 'alice@example.com';
console.log('userEmail:', userEmail); // alice@example.com

// ============================================================
// FUNCTION EXPRESSIONS — NOT fully hoisted (they behave like var/let/const)
// ============================================================

// Uncomment to see the error:
// formatName('Bob'); // ❌ TypeError: formatName is not a function
// At this point, var formatName is hoisted with value undefined
// Calling undefined() throws a TypeError

var formatName = function(name) {
  return `Hello, ${name}!`;
};

console.log(formatName('Bob')); // Hello, Bob!
▶ Output
Tax: 40
userName before assignment: undefined
userName after assignment: Alice
userEmail: alice@example.com
Hello, Bob!
⚠️
Watch Out: The Temporal Dead Zone Is Not a BugThe TDZ for let and const was a deliberate design decision. Getting undefined from var before initialisation silently hides bugs — your code keeps running with a broken value. Getting a ReferenceError from let/const fails loudly and immediately, pointing you straight to the problem. The TDZ makes your bugs easier to find, not harder.

The Loop Closure Trap — and How Scope Fixes It

The most famous real-world consequence of misunderstanding scope and hoisting is the loop closure bug. It shows up everywhere — event listeners attached inside loops, async operations started in loops, setTimeout calls inside loops. Every junior (and plenty of senior) developers have shipped this bug at least once.

The problem: when you use var in a for loop and create a closure inside that loop, all closures share the same variable — because var is function-scoped, not block-scoped. There's only one variable in memory, and by the time any closure runs, the loop has already finished and that one variable holds its final value.

The fix is either to use let (which creates a new binding for each loop iteration) or to use an immediately invoked function expression (IIFE) to capture the current value manually. The let solution is cleaner and is the reason let was introduced.

This bug is particularly nasty because the code looks correct. The logic is sound. The mistake is invisible unless you know the scope rules underneath. Understanding this one pattern will save you hours of debugging in real projects — especially any time you're attaching event handlers dynamically or firing async requests in a loop.

LoopClosureTrap.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// ============================================================
// THE BUG: var in a loop with closures
// ============================================================

const buggyTimers = [];

for (var taskNumber = 1; taskNumber <= 3; taskNumber++) {
  // We WANT each timer to remember its own taskNumber
  // But var is function-scoped — there's only ONE taskNumber variable
  // By the time these run, the loop has finished and taskNumber === 4
  buggyTimers.push(
    setTimeout(() => console.log('Buggy task:', taskNumber), 100)
  );
}
// Output after 100ms will be:
// Buggy task: 4
// Buggy task: 4
// Buggy task: 4

// ============================================================
// FIX 1: Use let — creates a NEW binding per iteration
// ============================================================

const fixedTimers = [];

for (let taskNumber = 1; taskNumber <= 3; taskNumber++) {
  // let gives each iteration its own separate 'taskNumber' in memory
  // Each closure closes over a different variable — the one for its iteration
  fixedTimers.push(
    setTimeout(() => console.log('Fixed task (let):', taskNumber), 200)
  );
}
// Output after 200ms:
// Fixed task (let): 1
// Fixed task (let): 2
// Fixed task (let): 3

// ============================================================
// FIX 2: IIFE (useful if you must support pre-ES6 environments)
// ============================================================

for (var legacyTask = 1; legacyTask <= 3; legacyTask++) {
  // The IIFE creates a new function scope on each iteration
  // We pass legacyTask as an argument, which captures its current value
  (function(capturedTask) {
    setTimeout(
      () => console.log('Fixed task (IIFE):', capturedTask),
      300
    );
  })(legacyTask);
}
// Output after 300ms:
// Fixed task (IIFE): 1
// Fixed task (IIFE): 2
// Fixed task (IIFE): 3

// ============================================================
// REAL-WORLD PATTERN: adding click handlers in a loop
// ============================================================

const menuItems = ['Home', 'Articles', 'Contact'];

menuItems.forEach((itemName, index) => {
  // forEach gives each iteration its own scope automatically
  // This is another clean way to avoid the loop closure trap
  console.log(`Registering handler for: ${itemName} at index ${index}`);
  // In a browser: button.addEventListener('click', () => navigate(itemName));
  // 'itemName' is correctly captured per iteration
});

console.log('Loop registration complete.');
▶ Output
Registering handler for: Home at index 0
Registering handler for: Articles at index 1
Registering handler for: Contact at index 2
Loop registration complete.
[after 100ms]
Buggy task: 4
Buggy task: 4
Buggy task: 4
[after 200ms]
Fixed task (let): 1
Fixed task (let): 2
Fixed task (let): 3
[after 300ms]
Fixed task (IIFE): 1
Fixed task (IIFE): 2
Fixed task (IIFE): 3
⚠️
Pro Tip: forEach Sidesteps the Problem EntirelyArray.forEach, map, filter, and reduce each create a new function scope per iteration — meaning each callback has its own private copy of the loop variables. If you're iterating over an array and creating closures, reaching for forEach instead of a for loop with var will save you from this class of bug completely. Use a for...of loop with let as your next best option.
Behaviourvarletconst
Scope levelFunction scopeBlock scopeBlock scope
Hoisted?Yes — as undefinedYes — but in TDZYes — but in TDZ
Access before declarationReturns undefined (silent bug)ReferenceError (loud failure)ReferenceError (loud failure)
Re-declaration in same scopeAllowed (dangerous)SyntaxErrorSyntaxError
Re-assignmentAllowedAllowedNot allowed
Attached to global object?Yes (window.myVar in browser)NoNo
Safe in loops with closures?No — one shared bindingYes — new binding per iterationYes — new binding per iteration
When to useLegacy code / almost neverAny value that will changeAny value that won't be reassigned

🎯 Key Takeaways

  • var is function-scoped and silently returns undefined when accessed before its assignment — this hides bugs. let and const are block-scoped and throw a ReferenceError in the TDZ — this exposes bugs immediately. The TDZ is a feature, not a flaw.
  • The scope chain is built at the point a function is written (lexical scoping), not where it's called. This is what makes closures work — an inner function retains a live reference to its outer scope even after that outer function has returned.
  • Function declarations are fully hoisted (name and body), so you can call them before they appear in your source. Function expressions (assigned to variables) are not fully hoisted — only the variable declaration is, so calling one before its definition gives you undefined() or a ReferenceError.
  • The loop closure bug — all closures sharing one var variable — is fixed cleanly by using let, which creates a new binding per iteration. When you write loops that spawn async work, event listeners, or any deferred callback, let is not optional — it's the correct tool.

⚠ Common Mistakes to Avoid

  • Mistake 1: Expecting var to be block-scoped — A developer declares var inside an if block or for loop, expecting it to be invisible outside, but it leaks into the enclosing function or global scope. Symptom: variables from inside loops appear (with unexpected values) outside the loop. Fix: replace var with let or const everywhere. If you're doing a code review, treat any var inside a block as a red flag.
  • Mistake 2: Calling a function expression before it's defined, expecting it to work like a function declaration — The developer writes processOrder() before const processOrder = function() {...} and gets a ReferenceError (with const/let) or a TypeError: processOrder is not a function (with var). Symptom: the code works fine if they rearrange the file but breaks when split into modules. Fix: understand the distinction — function declarations are fully hoisted, function expressions are not. Place function expressions before their call sites, or convert to a function declaration if early invocation is needed.
  • Mistake 3: Assuming the temporal dead zone only applies to const — Developers who learn 'let is like var but block-scoped' often miss that let also has a TDZ. They use a let variable before its declaration line, expect undefined like var would give, but get a ReferenceError instead. Symptom: confusing errors in code that 'looks fine'. Fix: always declare let and const at the very top of the block where you need them — mirror how the engine thinks about them. If you're accessing a variable and not sure if it's initialised, that's a sign the variable is in the wrong place.

Interview Questions on This Topic

  • QWhat is the difference between var, let, and const in terms of scoping and hoisting? Can you walk me through what happens when the JavaScript engine encounters each one before executing your code?
  • QWhat is the temporal dead zone and why does it exist? Why is getting a ReferenceError from let/const before initialisation considered better behaviour than var returning undefined?
  • QHere's a classic snippet — what does this print and why? `for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }` Now how would you fix it to print 0, 1, 2 — and what are two different ways to do it?

Frequently Asked Questions

Is hoisting only about var and function declarations in JavaScript?

No — let and const are also hoisted, but they're placed in the temporal dead zone (TDZ) rather than being initialised with undefined like var. The engine knows they exist from the start of their block, but any access before the actual declaration line throws a ReferenceError. So hoisting applies to all declarations; the difference is what state the variable is in when hoisted.

Why does JavaScript use function scope for var instead of block scope like most other languages?

JavaScript was designed in 10 days in 1995 with functions as the primary structuring unit. Function scope made sense in that context because functions were the main way to create privacy and modularity. Block scope via let and const was added in ES6 (2015) to align JavaScript with the developer expectations set by languages like C, Java, and Python, and to fix real-world bugs caused by var leaking out of loops and conditionals.

What exactly is a closure and how does it relate to scope?

A closure is a function that retains access to variables from its outer (enclosing) scope even after that outer function has finished executing. It's a direct consequence of lexical scoping — because a function's scope chain is set at definition time, the inner function always has a reference to the environment it was created in. Closures are used for private state, factory functions, event handler factories, and memoisation.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousCSS Specificity and CascadeNext →this Keyword in JavaScript
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged