JavaScript Engineering

JavaScript Scoping Bugs We've Seen in Production

Scoping bugs rarely show up in code review, they show up in production, under load, with async code involved. Here are five real ones, why they happen, and how to fix them for good.

April 2026 8 min read JavaScript  ·  Scoping  ·  ES6
Start Reading Back to Blog

In This Article

Five scoping bugs that show up in real codebases: the var-in-a-loop async trap, implicit globals, variable shadowing, the temporal dead zone, and block-scope leaks, each with a fix and a quick-reference table for var vs let vs const.

Need Help?

Working through a tricky JavaScript architecture or performance problem? Let's talk.

Talk to an Engineer
Deep Dive

Five Scoping Bugs, and How to Fix Them

A practical guide, April 2026  ·  8 min read

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
Featurevarletconst
ScopeFunctionBlockBlock
Re-declareYesNoNo
Re-assignYesYesNo
HoistingYes, initialized as undefinedYes, in TDZYes, in TDZ
New binding per loop iterationNoYesN/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.

Topics

JavaScript Scoping ES6 Best Practices

Related Articles

Higher-Order Functions That Simplify Enterprise JavaScript

Middleware chains, composition, memoization, and the closures that power them.

Prototype Pitfalls That Cause Production Bugs

Lost this context, shared mutable state, and missing new.

Distributed Rate Limiting

Building production-grade rate limiting with Redis and Spring Cloud Gateway.