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.
- 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.
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.
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.
- 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.
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.
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.
- 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.
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.
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.
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.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.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's JS Basics. Mark it forged?
4 min read · try the examples if you haven't