JavaScript Hoisting — Async Loop Bug Leaked User Data
A single var in a for loop intermittently caused user data to leak across sessions.
- 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.
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.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 definitionvar 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
That's JS Basics. Mark it forged?
3 min read · try the examples if you haven't