Mid-level 3 min · March 17, 2026

JavaScript Hoisting — Async Loop Bug Leaked User Data

A single var in a for loop intermittently caused user data to leak across sessions.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Scope defines where a variable lives — global, function (var), or block (let/const).
  • Hoisting lifts declarations to the top of their scope before execution.
  • Function declarations are fully hoisted — call them before they're defined.
  • var declarations are hoisted and initialised as undefined.
  • let and const are hoisted but not initialised — the temporal dead zone blocks access until the declaration line.
  • Biggest mistake: assuming let is not hoisted — it is, but the TDZ prevents reading before initialisation.

var vs let vs const — The Scope Difference

JavaScript offers three ways to declare variables, and each behaves differently regarding scope. var is function-scoped — it leaks out of blocks like if and for. let and const are block-scoped, meaning they respect any pair of curly braces. const also prevents reassignment of the binding, but it does not make objects immutable — you can still modify properties.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// var: function-scoped — ignores block boundaries
function testVar() {
  if (true) {
    var x = 10;  // declared inside if block
  }
  console.log(x); // 10 — accessible outside the block!
}
testVar();

// let: block-scoped — respects {} boundaries
function testLet() {
  if (true) {
    let y = 10;
  }
  // console.log(y); // ReferenceError — y not accessible here
}

// const: block-scoped + cannot be reassigned
const MAX = 100;
// MAX = 200; // TypeError: Assignment to constant variable

// const with objects — the binding is const, not the object
const user = { name: 'Alice' };
user.name = 'Bob';  // fine — mutating the object, not the binding
console.log(user.name); // Bob
Output
10
Bob
Scope is a Set of Boxes
  • Global scope is the outermost doll — visible everywhere.
  • Function scope is a doll inside global — var lives here, leaking out of smaller containers.
  • Block scope is a doll inside a function — let and const respect the boundaries of {}.
  • When JavaScript looks for a variable, it opens dolls from innermost to outermost, stopping at the first match.
Production Insight
In a large React codebase, we had a var inside a useEffect that leaked into the component scope, causing state updates to read stale values.
Switching to let eliminated the bug and made the code easier to reason about.
Rule: always use let or const inside hooks and event handlers — never var.
Key Takeaway
var treats the whole function as its home.
let and const treat every {} as a new wall.
Prefer const by default — it makes your intent explicit.
Which Declaration Should You Use?
IfThe variable should never be reassigned
UseUse const — it's the safest default and communicates intent.
IfThe value needs to change over time (e.g., loop counter, accumulating state)
UseUse let — it's block-scoped and reassignable.
IfYou need function-level scoping or are working in a legacy codebase that requires var
UseUse var — but be aware of the closure trap in loops and async callbacks.

Hoisting — What Actually Happens

Before executing any code, JavaScript's engine scans for declarations and processes them. Function declarations are fully hoisted. var declarations are hoisted but set to undefined. let and const are hoisted but not accessible until the declaration line.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Function declaration — fully hoisted, works before definition
console.log(greet('Forge'));  // 'Hello, Forge!' — works!

function greet(name) {
  return `Hello, ${name}!`;
}

// var — hoisted but undefined
console.log(city);  // undefined — hoisted, not initialised
var city = 'London';
console.log(city);  // 'London'

// let/const — temporal dead zone (TDZ)
// console.log(country);  // ReferenceError: Cannot access before initialization
let country = 'UK';
console.log(country);  // 'UK'

// Function expression — NOT hoisted (it is a variable assignment)
// console.log(sayHi());  // TypeError: sayHi is not a function
const sayHi = function() { return 'Hi!'; };
console.log(sayHi());  // 'Hi!'
Output
Hello, Forge!
undefined
London
UK
Hi!
Hoisting Does Not Move Your Code
A common misconception is that hoisting physically moves declarations to the top of the scope. It doesn't. The engine simply allocates memory for the declaraton during the creation phase. The code stays exactly where you wrote it — only the binding is pre-processed. So var x; at line 10 is still at line 10 in memory, but JavaScript knows that a variable x exists in the scope from the start.
Production Insight
We once had a bug where a var variable was declared inside a switch block without a case guard, and it was hoisted to the top of the function, causing undefined values in unexpected places.
The fix: switch to let to contain the variable within the switch.
Rule: if you need block scoping, never rely on var inside switch or if — use let or const.
Key Takeaway
Function declarations are fully hoisted — they work before definition.
var is hoisted but half-baked — undefined until assignment.
let / const are hoisted but locked in the TDZ — no access until the declaraton line.
How Does Hoisting Affect Your Code?
IfYou are using a function declaration
UseIt's fully hoisted — you can call it anywhere in the scope, even before its definition.
IfYou are using a var declaraton
UseThe declaraton is hoisted and initialized to undefined. The assignment stays in place.
IfYou are using let or const
UseThe declaraton is hoisted but the variable is in the TDZ — you cannot read or write it until the declaraton line.
IfYou are using a function expression (assigned to var or let)
UseThe variable is hoisted as per the declaration type, but the function body is not assigned until execution reaches that line.

The Temporal Dead Zone — Why let and const Are Not 'Safe' Hoisting

The temporal dead zone (TDZ) is the period between the start of a block scope and the point where a let or const variable is declared. During this period, the variable exists in memory (it has been hoisted) but is not initialized. Any attempt to read or write to it throws a ReferenceError.

The TDZ exists to catch a specific class of bugs: accessing a variable before it's initialized. With var, such access silently returns undefined, which can hide logical errors. The TDZ makes the error visible immediately.

This behaviour applies to class, import, and const as well. It's a deliberate design choice to make the language safer.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// TDZ example with let
{
  // TDZ starts here for `a`
  // console.log(a); // ReferenceError: Cannot access 'a' before initialization
  let a = 10; // TDZ ends here
  console.log(a); // 10
}

// TDZ is also scope-specific
function test() {
  if (true) {
    // TDZ for `x` starts
    // console.log(x); // ReferenceError
    let x = 5;
    console.log(x); // 5
  }
  // TDZ for `x` ended when the block closed, but `x` is no longer accessible
}

// typeof in TDZ also throws
{
  // console.log(typeof y); // ReferenceError in TDZ for let
  let y = 1;
}

// const has the same TDZ behaviour
{
  // const z = z + 1; // ReferenceError — z is in TDZ
  const z = 1;
}
Output
10
5
1
TDZ Is Not a Bug — It's a Feature
The TDZ prevents you from accidentally using a variable before it has its intended value. Without it, let would be as permissive as var, and bugs would remain silent. The ReferenceError is a signal that your code's order is wrong.
Production Insight
In a Node.js microservice, a developer placed const config = require('./config') at the bottom of a module, and then imported it at the top — that config was in the TDZ. The service crashed on startup with a confusing ReferenceError.
The fix: move all require and import statements to the top of the file.
Rule: always declare imports and constants before any code that uses them, to avoid TDZ surprises.
Key Takeaway
TDZ turns silent bugs into loud errors.
The TDZ runs from block start to declaraton line.
Rule: declare all let/const variables at the beginning of their scope to minimize confusion.

Lexical Scope and Closures

JavaScript uses lexical scoping, meaning that the scope of a variable is determined by its position in the source code. Inner functions have access to variables of outer functions, but not vice versa.

Closures occur when an inner function retains access to its outer scope even after the outer function has finished executing. This is the foundation for many JavaScript patterns, including data privacy, event handlers, and callbacks.

Understanding closures is essential for debugging scope-related bugs, especially in asynchronous code.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const globalVar = 'global';

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

  function inner() {
    const innerVar = 'inner';
    // Can access all three — lexical scope chain
    console.log(innerVar);   // 'inner'
    console.log(outerVar);   // 'outer'
    console.log(globalVar);  // 'global'
  }

  inner();
  // console.log(innerVar); // ReferenceError — inner scope not accessible here
}

outer();

// Scope chain: inner → outer → global → undefined
// JS looks up the chain until it finds the variable or exhausts the chain
Output
inner
outer
global
The Scope Chain Is a Backpack
  • When inner() is created inside outer(), it gets a reference to outer()'s scope chain.
  • That reference persists even after outer() finishes — that's the closure.
  • The backpack contains variables, not their values — so changes to outer variables are seen by the closure.
  • This is why loops with var and callbacks behave unexpectedly — all closures share the same backpack variable.
Production Insight
In a real-time chat application, we used setInterval with a closure that captured a var counter. Each interval callback saw the same incremented value, causing duplicate messages.
Switching to let inside the loop created a new binding per iteration, fixing the bug.
Rule: when creating closures inside loops, always use let or an IIFE to capture the current value per iteration.
Key Takeaway
Closures keep the outer scope's backpack with them.
var in loops creates one shared variable — let creates per-iteration bindings.
Use closures for data privacy, but always be aware of what's in the backpack.
When Do Closures Cause Problems?
IfYou are creating functions inside a for loop with var
UseAll functions share the same variable — expect surprising values when they execute later.
IfYou are creating event handlers inside a loop with var
UseSame problem — each handler sees the final value of the loop variable.
IfYou are using let in a loop
UseEach iteration gets its own binding — closures work as expected.
IfYou want to create a private variable using closure
UseUse an IIFE or a function expression to return an object with methods that have access to the private variable.

Hoisting of Function Declarations vs Function Expressions

Function declarations and function expressions behave very differently when it comes to hoisting. A function declaration is fully hoisted — both the declaration and the function body are moved to the top of the enclosing scope. A function expression is treated as a variable assignment — only the variable declaraton is hoisted (if var), and the function body is not assigned until the executable code reaches that line.

This distinction catches many developers off guard. If you call a function expression before its definition, you'll get a TypeError because the variable is undefined (if var) or a ReferenceError (if let/const due to TDZ).

Recommended practice: use function declarations for top-level functions that need to be called anywhere in the scope. Use function expressions for callbacks and when you want to limit the function's hoisting to avoid confusion.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Function declaration — fully hoisted
console.log(sum(2, 3)); // 5 — works!

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

// Function expression assigned to var — variable hoisted, function not
console.log(multiply); // undefined (var hoisted)
// console.log(multiply(2, 3)); // TypeError: multiply is not a function
var multiply = function(a, b) {
  return a * b;
};
console.log(multiply(2, 3)); // 6 — now it works

// Function expression assigned to let — not accessible before declaration
// console.log(divide(6, 2)); // ReferenceError: Cannot access 'divide' before initialization
const divide = function(a, b) {
  return a / b;
};
console.log(divide(6, 2)); // 3

// Arrow functions are also function expressions
// console.log(subtract(5, 2)); // ReferenceError
const subtract = (a, b) => a - b;
console.log(subtract(5, 2)); // 3
Output
5
undefined
6
3
3
Don't Rely on Hoisting for Function Expressions
Function expressions (including arrow functions) are not hoisted. If you need to call a function before its definition, use a function declaration. Otherwise, define all expressions at the top of the scope to avoid confusion.
Production Insight
In a CI/CD pipeline script, a developer used a function expression inside a try block and called it later in the catch block — but the var hoisting made the variable undefined in the catch, causing a silent failure that went unnoticed for weeks.
The fix: convert to a function declaration or move the expression outside the try/catch.
Rule: never rely on hoisting of function expressions for critical execution paths.
Key Takeaway
Function declarations are fully hoisted — use them for top-level reusable logic.
Function expressions are hoisted only as variable declarations — the function body remains where it is.
Arrow functions behave like function expressions — they are not hoisted.
● Production incidentPOST-MORTEMseverity: high

The Hoisting Bug That Leaked User Data

Symptom
API responses intermittently returned the wrong user data — users occasionally saw another user's dashboard.
Assumption
The team assumed var inside a for loop would be scoped to the loop block, as in other languages.
Root cause
var is function-scoped, not block-scoped. The loop variable i was shared across all iterations. When asynchronous callbacks executed after the loop, they all saw the final value of i.
Fix
Replace var with let in the loop declaration, which creates a new binding per iteration. Also refactored the async pattern to use Array.prototype.forEach with closures.
Key lesson
  • Never use var inside a for loop that contains asynchronous callbacks.
  • Treat let as the default for loop variables — it eliminates an entire class of closure bugs.
  • Review all loops with async operations: the closure captures the variable, not its value.
Production debug guideSymptom → Action reference for the most common scope-related production bugs.4 entries
Symptom · 01
Variable returns undefined instead of expected value
Fix
Check if the variable is declared with var and the assignment occurs later. Add a breakpoint before the assignment and inspect the variable's value in the Scope panel of DevTools.
Symptom · 02
ReferenceError: Cannot access before initialization
Fix
The variable is in the temporal dead zone. Move the declaration above the code that accesses it. Verify you haven't hoisted a let or const by referencing it earlier in the block.
Symptom · 03
Asynchronous callback uses the wrong variable value
Fix
Likely a closure capturing a var variable. Replace var with let in the loop. If you must keep var, create an IIFE to capture the current value per iteration.
Symptom · 04
Function expression is undefined when called before definition
Fix
Function expressions are not hoisted. Move the function expression definition above the call site, or convert it to a function declaration.
★ Quick Scope Debug Cheat SheetThree commands to diagnose scope issues in Node.js and browser DevTools.
Variable is undefined in a block but expected a value
Immediate action
Open DevTools Sources panel, add a breakpoint on the line where the variable is first accessed. Reload.
Commands
In the console, type `varName` and press Enter to see its current value.
Type `debugger;` in your code above the suspect line to pause execution automatically.
Fix now
Move the declaration above the block or change var to let if the block is inside a function.
`let` / `const` variable throws ReferenceError on first use+
Immediate action
Scroll up in the code to find where the variable is declared. Ensure the declaration line appears before any code that reads it.
Commands
In browser console: `typeof myVar` — returns 'undefined' if not declared, ReferenceError if in TDZ but not initialized.
In Node.js: `node --inspect-brk` then use Chrome DevTools to step through the function to see the TDZ boundary.
Fix now
Swap the declaration and the first use order. If the variable is conditionally declared, use an IIFE or block scope to guarantee initialization before access.
Function call works but returns different value than expected+
Immediate action
Check if the function is a declaration (hoisted) or expression (not hoisted). Look for a function expression assigned to a `var`.
Commands
In console: `typeof myFunc` — returns 'function' if hoisted declaration, 'undefined' if expression before assignment.
Add `console.log(myFunc.toString())` just before the call to see if the function body is what you expect.
Fix now
If it's a function expression, move its definition above the call site. If it's a declaration, verify no other declaration with the same name shadows it.
Quick Reference: Declaration Types
Propertyvarletconst
ScopeFunction-scopedBlock-scopedBlock-scoped
Hoisting behaviourHoisted, initialised to undefinedHoisted, in TDZ until declarationHoisted, in TDZ until declaration
ReassignmentAllowedAllowedForbidden (binding is constant)
RedeclarationAllowed (same scope)SyntaxErrorSyntaxError
Temporal Dead ZoneNoYesYes
When to useLegacy code (avoid in modern)When reassignment is neededBy default
Loop with closuresBug-prone (shared binding)Safe (new binding per iteration)Safe (new binding per iteration)

Key takeaways

1
var is function-scoped; let and const are block-scoped.
2
Function declarations are fully hoisted
you can call them before they are defined.
3
var is hoisted and initialised to undefined; let/const are hoisted but in the temporal dead zone.
4
The temporal dead zone is the period between hoisting and the actual declaration line.
5
Prefer const by default; use let when you need to reassign; avoid var in modern code.

Common mistakes to avoid

5 patterns
×

Assuming var is block-scoped

Symptom
A variable declared with var inside an if block is accessible outside that block, causing unexpected values and hard-to-find bugs.
Fix
Replace var with let or const when you intend the variable to be limited to a block. If you must use var, be aware that it belongs to the entire function.
×

Calling a function expression before its definition

Symptom
TypeError: ... is not a function when calling a function expression (or arrow function) before the line where it's assigned.
Fix
Use a function declaration if you need to call it before the definition. Or move the function expression above all code that calls it.
×

Misunderstanding the temporal dead zone

Symptom
ReferenceError: Cannot access 'x' before initialization — even though the variable is declared with let in the same block.
Fix
Move the declaration to the top of the block, or reorder your code so that any code accessing the variable appears after the declaration.
×

Using var in a for loop with async callbacks

Symptom
All callbacks see the final value of the loop variable, leading to incorrect behaviour (e.g., all API calls use the same index).
Fix
Replace var with let in the loop declaration. If you must use var, wrap the callback body in an IIFE to capture the current value.
×

Forgetting that const does not make objects immutable

Symptom
Developers assume a const object cannot be modified, so they don't guard against mutations, leading to state corruption.
Fix
Understand that const only prevents reassignment of the binding, not mutation of the object. Use Object.freeze() for shallow immutability, or use immutable patterns (e.g., spread operator).
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between var, let, and const in terms of scope?
Q02SENIOR
What is the temporal dead zone?
Q03SENIOR
What is the difference between hoisting of function declarations and fun...
Q04SENIOR
How does hoisting affect closures in loops?
Q05SENIOR
What is the output of the following code? ```javascript console.log(a); ...
Q01 of 05JUNIOR

What is the difference between var, let, and const in terms of scope?

ANSWER
var is function-scoped: it is accessible anywhere within the function where it's declared, even outside block statements like if or for. let and const are block-scoped: they are only accessible within the nearest pair of curly braces. Additionally, const prevents reassignment of the binding, though it does not make objects immutable.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the temporal dead zone?
02
Why does var inside a for loop leak out of the loop?
03
Can I use const for objects and still modify their properties?
04
Is it true that let and const are not hoisted?
05
What's the best practice for variable declaration in modern JavaScript?
🔥

That's JS Basics. Mark it forged?

3 min read · try the examples if you haven't

Previous
Type Coercion in JavaScript
11 / 16 · JS Basics
Next
this Keyword in JavaScript