Scoping Bugs Don't Show Up in Code Review
They show up in production, usually under load, usually with async code involved, and usually at the worst possible time. Here are five real scoping bugs, why they happen, and how to fix them for good.
Bug 01, The var-in-a-Loop Async Trap
Every Callback Logs the Same Value
This is probably the single most common JavaScript scoping bug in production code. It shows up anywhere a loop schedules async work, timers, event listeners, or API calls.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 3, 3, 3 — not 0, 1, 2
var is function-scoped, not block-scoped. There is only one i for the entire loop. By the time the setTimeout callbacks actually run, the loop has already finished and i is 3. All three closures share the same variable.
Where this bites in production: Batch API calls in a loop, retry logic with delays, or rendering a list of buttons that each need to "remember" their own index.
The fix: use let. Unlike var, let creates a fresh binding for i on every iteration, so each closure captures its own copy.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 0, 1, 2
Why? Reason enough on its own to default to let/const in loops and never reach for var.
Bug 02, Implicit Globals
The Missing Declaration
Forget let, const, or var in front of an assignment, and JavaScript quietly creates a global variable instead of throwing an error.
function processOrder(items) {
total = 0; // no declaration — leaks to global scope
for (const item of items) {
total += item.price;
}
return total;
}
processOrder([{ price: 20 }, { price: 30 }]);
console.log(total); // 50 — accessible from anywhere, including other modules
total was meant to be local to processOrder. Instead it's now sitting on the global object, readable and writable by any other code that happens to run on the page, including other scripts, browser extensions, or a completely unrelated function that reuses the name total.
The fix: use 'use strict' (or ES modules, which are strict by default) so this becomes a ReferenceError at the exact line that caused it, instead of a silent bug that surfaces somewhere else entirely.
Bug 03, Variable Shadowing
The Wrong Value, Silently
Shadowing isn't inherently a bug, it's how block scope is supposed to work. It becomes a bug when a developer assumes they're updating the outer variable and are actually creating a new one.
let userId = getCurrentUserId(); // e.g. 501
function logAction(action) {
if (action.impersonatedBy) {
let userId = action.impersonatedBy; // shadows the outer userId
console.log(`Acting as ${userId}`);
}
// Bug: code here still expects the impersonated userId,
// but it's out of scope. This block sees the original 501.
auditLog.record(userId, action.type);
}
The inner userId only exists inside the if block. Once execution leaves it, auditLog.record silently uses the outer userId, not the impersonated one. No error, no warning, just a wrong value flowing downstream.
The fix: give shadowed variables distinct names (effectiveUserId, not another userId), or restructure so the value that needs to escape the block is explicitly returned or assigned to an outer let.
Bug 04, The Temporal Dead Zone
Referencing a Variable Before It's Declared
let and const are hoisted, like var, but they aren't initialized until their declaration line runs. The gap between the start of the scope and the declaration is the "temporal dead zone" (TDZ).
console.log(typeof configuredVar); // "undefined" — var hoists and initializes
console.log(typeof configuredLet); // ReferenceError — still in the TDZ
var configuredVar = loadConfig();
let configuredLet = loadConfig();
This most often surfaces after refactoring, moving a function call above the let/const it depends on, inside a module where execution order isn't obvious at a glance, or in code that used to rely on var's hoist-and-initialize-as-undefined behavior.
The fix: declare variables before you use them. This sounds obvious, but the TDZ specifically punishes code that used to "work" under var's more forgiving hoisting and silently breaks after a let/const conversion.
Bug 05, Block-Scope Leaks with var
var Doesn't Respect if or for Blocks
function checkAccess(user) {
if (user.role === 'admin') {
var accessLevel = 'full';
} else {
var accessLevel = 'restricted';
}
// accessLevel is visible here too — by accident, not by design
console.log(accessLevel);
}
This particular example happens to work, but the danger is what it teaches developers to assume: that var declared in a block stays in that block. It doesn't. A variable declared with var anywhere inside a function is visible across the entire function, including before the if/for block it was declared in.
function processItems(items) {
console.log(temp); // undefined, not a ReferenceError — var was hoisted
if (items.length > 0) {
var temp = items[0];
}
}
The fix: use let/const, which are genuinely block-scoped. If you need a value to escape a block, declare it explicitly in the outer scope first.
Quick Reference
var vs let vs const
| Feature | var | let | const |
| Scope | Function | Block | Block |
| Re-declare | Yes | No | No |
| Re-assign | Yes | Yes | No |
| Hoisting | Yes, initialized as undefined | Yes, in TDZ | Yes, in TDZ |
| New binding per loop iteration | No | Yes | N/A (can't reassign) |
Final Takeaway
Four of these five bugs disappear the moment a codebase stops using var. The fifth, shadowing, gets caught by naming variables for what they actually represent, not reusing a name because it's convenient.
Scoping isn't a theoretical concept. It's the difference between code that works in a demo and code that survives production traffic.