JavaScript Hoisting — Async Loop Bug Leaked User Data
A single var in a for loop intermittently caused user data to leak across sessions.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
letis not hoisted — it is, but the TDZ prevents reading before initialisation.
Hoisting is JavaScript's behavior of moving variable and function declarations to the top of their containing scope during compilation, before any code executes. It exists because the language was designed to allow function declarations to be called before they appear in source code — a convenience that predates block scoping entirely.
The problem is that var declarations are hoisted and initialized with undefined, while let and const are hoisted but remain uninitialized in the Temporal Dead Zone (TDZ) until their actual declaration line. This subtle difference is why var in async loops creates shared mutable state across iterations — each callback closes over the same hoisted variable, which has already mutated by the time the async operation resolves.
In production systems, this has caused real data leaks: for example, a Node.js API endpoint using var in a for loop with async database calls could expose one user's data to another because all callbacks reference the final loop value. The fix is using let (block-scoped, per-iteration binding) or const for immutable references, though even let requires understanding that its hoisting creates a TDZ — accessing it before declaration throws a ReferenceError, not undefined.
Function declarations are fully hoisted (callable anywhere in scope), while function expressions assigned to var are hoisted as undefined, causing TypeError: not a function if invoked too early. This isn't academic — Airbnb, Uber, and countless fintech apps have shipped bugs where hoisting in async patterns caused race conditions that leaked PII or corrupted financial transactions.
Imagine you're handing out numbered tickets at a deli counter, but you write the number on a whiteboard instead of giving each person their own slip. By the time the first customer is served, the whiteboard already shows the last number you wrote — so everyone gets called with the same wrong number. That's what var does in an async loop: all the delayed actions see the final value, not the one you intended.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
A single var in a for loop with async callbacks caused user data to leak across sessions in production Node.js services. The root cause is JavaScript hoisting — a compile-time behavior that moves variable declarations to the top of their scope. Understanding how var, let, and const hoist differently is the difference between a secure pagination endpoint and a PII leak. This bug has shipped at Airbnb, Uber, and fintech apps, corrupting financial transactions and exposing sensitive records.
Why Hoisting Breaks Async Loops — And Leaks Data
Scope hoisting in JavaScript is the engine's compile-time behavior of moving variable and function declarations to the top of their enclosing scope before execution. The key mechanic: var declarations are hoisted and initialized with undefined; let and const are hoisted but remain in a temporal dead zone (TDZ) until the actual declaration line. This means a var inside a block is accessible outside it — a property that directly causes the classic async loop bug.
In practice, hoisting interacts with closures and asynchronous callbacks in dangerous ways. When you write for (var i = 0; i < 5; i++) { setTimeout(() => console.log(i), 0); }, the var i is hoisted to the function scope, not block scope. By the time the callbacks execute, i is already 5. The same bug leaks user data when the loop variable is used to index into an array of sensitive records — every callback sees the last value, not the intended one.
Use let for block-scoped variables to avoid this entirely. In production systems, always prefer let or const in loops that schedule async work. If you must use var, capture the current value with an IIFE or .bind(). This isn't academic — it's a top-10 source of data leakage in Node.js services handling paginated queries.
let and const are hoisted but uninitialized until their declaration. Accessing them before that line throws a ReferenceError — this is intentional, not a quirk.var i in a for loop to fetch user records asynchronously. Every concurrent request returned the last user's data instead of the correct one.var in a loop that schedules async callbacks — always use let or capture the index explicitly.var gets undefined, let/const get TDZ.var share the same variable across all callbacks — always use let for block scoping.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.
// 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
- Global scope is the outermost doll — visible everywhere.
- Function scope is a doll inside global —
varlives here, leaking out of smaller containers. - Block scope is a doll inside a function —
letandconstrespect the boundaries of{}. - When JavaScript looks for a variable, it opens dolls from innermost to outermost, stopping at the first match.
var inside a useEffect that leaked into the component scope, causing state updates to read stale values.let eliminated the bug and made the code easier to reason about.let or const inside hooks and event handlers — never var.var treats the whole function as its home.let and const treat every {} as a new wall.const by default — it makes your intent explicit.const — it's the safest default and communicates intent.let — it's block-scoped and reassignable.varvar — 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.
// 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!'
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.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.let to contain the variable within the switch.var inside switch or if — use let or const.var is hoisted but half-baked — undefined until assignment.let / const are hoisted but locked in the TDZ — no access until the declaraton line.var declaratonundefined. The assignment stays in place.let or constvar or let)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.
// 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; }
let would be as permissive as var, and bugs would remain silent. The ReferenceError is a signal that your code's order is wrong.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.require and import statements to the top of the file.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.
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
- When
is created insideinner(), it gets a reference toouter()'s scope chain.outer() - That reference persists even after
finishes — that's the closure.outer() - The backpack contains variables, not their values — so changes to outer variables are seen by the closure.
- This is why loops with
varand callbacks behave unexpectedly — all closures share the same backpack variable.
setInterval with a closure that captured a var counter. Each interval callback saw the same incremented value, causing duplicate messages.let inside the loop created a new binding per iteration, fixing the bug.let or an IIFE to capture the current value per iteration.var in loops creates one shared variable — let creates per-iteration bindings.for loop with varvarlet in a loopHoisting 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.
// 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
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.Block Scope Is a Lie — Until ES6
Here's where most junior engineers burn production systems. You see curly braces, you assume a new scope. In C, Java, even C#, that's true. In JavaScript before ES2015? The if block, for loop, while — they don't create a scope. var laughs at your blocks. It gets hoisted to the nearest function or global scope. So when you write for (var i = 0; i < 5; i++) and schedule an async callback referencing i, every callback sees 5. Not 0,1,2,3,4. That's not a bug. That's var doing exactly what it was designed to do: ignore block boundaries. ES2015 introduced let and const. They actually respect blocks. No hoisting leak. No shared mutable state across iterations. Never use var in a block again. Your async code will thank you.
// io.thecodeforge // Before ES6: var ignores block scope function leakyLoop() { for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // 3, 3, 3 } } leakyLoop(); // After ES6: let respects block scope function safeLoop() { for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // 0, 1, 2 } } safeLoop();
var to let inside a for loop changes iteration behavior fundamentally. Test every async path.let and const create true block scopes. var only respects function boundaries.Class Hoisting — The Silent Import Killer
You think hoisting only affects var and function declarations? Wrong. Class declarations are hoisted too — but not in the way you expect. They are hoisted to the top of their scope, but they remain uninitialized until the actual declaration is evaluated. This means you cannot use a class before its definition, even if it's hoisted. Try accessing new before the MyClass()class keyword appears? You get a ReferenceError. This trips up anyone importing classes conditionally or reordering module exports during refactoring. The fix is simple: always declare classes at the top of their module, before any usage. No exceptions. For function-like constructors, use class syntax, not function — it enforces the temporal dead zone and catches errors at compile time instead of runtime.
// io.thecodeforge // Class hoisting — temporal dead zone applies // This throws: ReferenceError: Cannot access 'User' before initialization function createAdmin() { return new User('admin'); } class User { constructor(name) { this.name = name; } } console.log(createAdmin()); // Correct: declare before use class Product { constructor(sku) { this.sku = sku; } } function createProduct(sku) { return new Product(sku); } console.log(createProduct('PROD-001'));
class declarations at module level, not inside blocks or conditionals. Let bundlers handle the order.The Hoisting Bug That Leaked User Data
var inside a for loop would be scoped to the loop block, as in other languages.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.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.- Never use
varinside aforloop that contains asynchronous callbacks. - Treat
letas 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.
undefined instead of expected valuevar and the assignment occurs later. Add a breakpoint before the assignment and inspect the variable's value in the Scope panel of DevTools.let or const by referencing it earlier in the block.var variable. Replace var with let in the loop. If you must keep var, create an IIFE to capture the current value per iteration.undefined when called before definitionIn 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.var to let if the block is inside a function.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.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.| Property | var | let | const |
|---|---|---|---|
| Scope | Function-scoped | Block-scoped | Block-scoped |
| Hoisting behaviour | Hoisted, initialised to undefined | Hoisted, in TDZ until declaration | Hoisted, in TDZ until declaration |
| Reassignment | Allowed | Allowed | Forbidden (binding is constant) |
| Redeclaration | Allowed (same scope) | SyntaxError | SyntaxError |
| Temporal Dead Zone | No | Yes | Yes |
| When to use | Legacy code (avoid in modern) | When reassignment is needed | By default |
| Loop with closures | Bug-prone (shared binding) | Safe (new binding per iteration) | Safe (new binding per iteration) |
| File | Command / Code | Purpose |
|---|---|---|
| blockScoping.js | function leakyLoop() { | Block Scope Is a Lie |
| classHoisting.js | function createAdmin() { | Class Hoisting |
Key takeaways
Common mistakes to avoid
5 patternsAssuming var is block-scoped
var inside an if block is accessible outside that block, causing unexpected values and hard-to-find bugs.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
Misunderstanding the temporal dead zone
let in the same block.Using var in a for loop with async callbacks
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
const object cannot be modified, so they don't guard against mutations, leading to state corruption.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 Questions on This Topic
What is the difference between var, let, and const in terms of scope?
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.What is the temporal dead zone?
let or const variable is declared. During this time, the variable exists but is not initialised — any attempt to access it throws a ReferenceError. This is a deliberate design to catch bugs where you access a variable before its intended initialisation. Unlike var, which silently returns undefined in the same scenario, the TDZ makes errors visible.What is the difference between hoisting of function declarations and function expressions?
var) or subject to the TDZ (if using let/const). The function body assignment happens only when execution reaches that line. Calling a function expression before its definition results in a TypeError or ReferenceError.How does hoisting affect closures in loops?
var in a for loop, the loop variable is hoisted to the function scope and shared across all iterations. Asynchronous callbacks or closures created inside the loop capture a reference to that single variable, not its value at each iteration. Thus, when the callbacks eventually execute, they all see the final value of the loop variable. Using let in the loop creates a new binding for each iteration, so each closure captures its own distinct value, avoiding the problem.What is the output of the following code? ```javascript console.log(a); var a = 5; console.log(b); let b = 10; ```
undefined followed by a ReferenceError. var a is hoisted and initialised to undefined, so the first console.log prints undefined. let b is hoisted but in the temporal dead zone — any attempt to access it before the declaration line throws a ReferenceError, so the script stops at that point.Frequently Asked Questions
The TDZ is the period from the start of the block scope until the let or const declaration is reached. The variable exists (it has been hoisted) but cannot be accessed — reading it throws ReferenceError. This is by design to prevent bugs that var's hoisting allows.
Because var is function-scoped, not block-scoped. The for loop's curly braces do not create a new scope for var. The variable lives in the containing function. This is the same reason the loop closure trap happens. Use let in for loops to get a new binding per iteration.
Yes. const only prevents reassignment of the binding (e.g., const obj = {}; obj = {} throws TypeError), but it does not make the object immutable. You can freely add, modify, or delete properties of the object. For shallow immutability, use Object.freeze().
No, that's a misconception. let and const are hoisted — the engine knows about them from the start of the block. However, they are placed in the temporal dead zone, which prevents any access until the actual declaration line. This is different from var, which is hoisted and initialised to undefined.
Use const by default for variables that should not be reassigned. Use let when you need to reassign the variable (e.g., loop counters, accumulators). Avoid var entirely in new code, as its function-scoping and hoisting behaviour leads to bugs that are harder to catch.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's JS Basics. Mark it forged?
3 min read · try the examples if you haven't