
I once watched a developer spend the better part of an hour hunting a bug that turned out to be three lines of code. The culprit? A for loop using var. The symptoms? A timer callback that printed the number 3 three times in a row when it was supposed to print 0, 1, 2. She kept adding console.log statements everywhere, convinced something was mutating her variable. Nothing was. The loop had already finished running before any of the callbacks fired, and by then, the variable held 3.
That's a closure bug. More specifically, it's what happens when you don't understand how JavaScript tracks where a variable lives.
We'll get to that specific bug. But first, you need to understand why variables have limits in the first place.
Why Variables Aren't Available Everywhere
Imagine JavaScript had no scope rules at all. Every variable declared anywhere in your program would be visible everywhere, all the time. Sounds convenient, right?
Here's what breaks immediately.
// Without scope, this would be chaos
function calculateTax() {
let total = 0;
// ... tax logic
}
//
function calculateShipping() {
let total = 0;
// ... shipping logic
}Both functions use a variable called total. Without scope, they'd be fighting over the same memory slot. One function runs and sets total to 500. The other runs and reads total expecting 0 but finds 500. Your bill would be completely wrong. You'd have no way to isolate logic inside a function from the rest of the program.
Scope is JavaScript's answer to that problem. Every variable has a boundary: the region of code where it exists, where it can be read, and where it gets cleaned up from memory when it's no longer needed.
Closures are the interesting edge case. They're what happens when a function carries its scope with it - when variables outlive the code that created them, because something is still holding on.
What Scope Actually Is
Variables Have an Address, Not Just a Name
Scope is the visibility boundary of a variable. It defines where in your code a variable is created, where it can be read or changed, and when it's destroyed.
JavaScript uses Lexical Scoping, also called Static Scoping. The word "lexical" just means "relating to source code." The rule is this: a variable's scope is fixed by where you physically write the code in the file, not by where or how that code gets called at runtime.
This catches beginners every time. The instinct is to think "the function was called from here, so it should see the variables here." That's not how it works.
// Lexical scope is fixed when code is written - NOT when it's called
const outerVar = "I am outer";
//
function printVar() {
// printVar was written at the file's top level.
// So its scope is the global (file-level) scope.
console.log(outerVar); // sees outerVar because it was DEFINED here
}
//
function caller() {
// This creates a local variable that shadows the outer one.
const outerVar = "I am inside caller";
//
// printVar() is CALLED from inside caller - but its scope
// was already decided when it was WRITTEN at the top level.
printVar(); // Output: "I am outer"
// NOT "I am inside caller" - because scope is fixed by WHERE it's written
}
//
caller();printVar was written at the global level. That's its home. Call it from inside caller, call it from inside a button click handler, call it from anywhere - it still looks at the global scope for outerVar, because that's where it was defined.
This is the single most important thing to understand before anything else clicks.
The Four Containers JavaScript Uses for Variables
Global Scope - The Variable That Lives Everywhere
A variable declared outside any function or block lives in the Global Scope. It exists for the full lifetime of the program and can be read or changed from anywhere in your code.
// Declared at the top level - global scope
let globalVar = "I exist everywhere";
//
function showGlobal() {
console.log(globalVar); // Works fine from inside a function
}
//
showGlobal(); // Output: "I exist everywhere"
console.log(globalVar); // Output: "I exist everywhere" - accessible anywhereThere's a meaningful difference between var, let, and const at the global level in browsers. var physically attaches to the window object (the browser's global object). let and const do not.
// var at the top level → attaches to window in browsers
var appName = "MyStore";
console.log(window.appName); // "MyStore" - it's now a property of window!
//
// let and const at the top level → do NOT attach to window
let userCount = 0;
console.log(window.userCount); // undefined - it's NOT on windowThis matters in real code. If you have two <script> tags both declaring var score, they'll collide because they're both writing to window.score. That's the global scope pollution problem. Using let and const keeps your variables out of the global object entirely.
Function Scope - The Variable That Only Lives Inside One Function
Variables declared inside a function body exist exclusively within that function. They're created when the function is called and destroyed when it returns.
function makeCounter() {
// count is trapped inside makeCounter's scope
let count = 0;
count++;
console.log(count); // Output: 1
}
//
makeCounter();
//
// count died when makeCounter() finished. It's gone.
console.log(count); // ReferenceError: count is not definedA common confusion: "I called the function, so count should exist now, right?" No. It existed during the function's execution. When the function returned, JavaScript cleaned it up. It's gone.
Each new call to the function creates a completely fresh set of local variables. They don't carry over between calls.
function greet() {
let message = "Hello";
console.log(message);
}
//
greet(); // Creates a fresh 'message', logs it, destroys it
greet(); // Creates another fresh 'message', logs it, destroys it
// These are two completely separate variables, not one shared oneFunction scope works the same whether you use function greet() declarations, const greet = function() expressions, or const greet = () => arrow functions. The curly braces of the function body create the scope.
Block Scope - The Variable That Only Lives Inside {}
A block is any section of code wrapped in curly braces: if statements, for loops, while loops, switch statements, or even a standalone pair of {}.
let and const are block-scoped. They exist only inside the {} where they're declared and die at the closing }.
// The if statement's {} creates a block scope
if (true) {
let blockVar = "only here";
const alsoBlock = "also only here";
console.log(blockVar); // Output: "only here" - works inside the block
}
//
// Outside the block - these variables died at the closing }
console.log(blockVar); // ReferenceError: blockVar is not defined
console.log(alsoBlock); // ReferenceError: alsoBlock is not definedvar does not respect blocks. At all. It passes straight through {} boundaries as if they don't exist. This is the root of most var bugs.
// Same code - but with var
if (true) {
var leakyVar = "I escape!";
}
//
// var completely ignores the if block's {} boundaries
console.log(leakyVar); // "I escape!" - no error, no warningHere's a visual of how the three scopes nest inside each other:
// Global Scope ─────────────────────────────────────────────
let globalThing = "visible everywhere";
//
function outer() {
// Function Scope ─────────────────────────────────────────
let functionThing = "visible inside outer()";
//
if (true) {
// Block Scope ───────────────────────────────────────────
let blockThing = "visible only inside this if block";
//
console.log(globalThing); // Works - looks up to global scope
console.log(functionThing); // Works - looks up to function scope
console.log(blockThing); // Works - right here
}
//
console.log(globalThing); // Works
console.log(functionThing); // Works
// console.log(blockThing); // ReferenceError - can't look DOWN into blocks
}The rule holds at every level: inner code can see outer variables, but outer code cannot look into inner scopes.
Module Scope: The Variable That Lives Inside a File
In modern JavaScript (ES6 and later), if you are writing code inside a module file, top-level variable declarations do not leak out into the global scope. Instead, they remain strictly isolated to the file itself. This isolation acts as a massive firewall against global window object pollution, ensuring that variables named config or data in one file do not accidentally overwrite variables with the identical name in another file.
// Imagine this is a file named auth.mjs
//
// Because this file is a module, top-level variables do NOT attach
// to the global window object. They are locked inside this file.
const apiKey = "12345";
//
// Other files cannot accidentally read or overwrite apiKey unless
// we explicitly choose to export it using the export keyword.
export function authenticate() {
// This function has access to the module-scoped apiKey
return "Authenticating with " + apiKey;
}The var Problem: When Variables Escape
Every Place var Leaks
var has exactly one scope rule: it's contained by function boundaries. Everything else - every if, every for, every while, every switch, every standalone {} - is completely transparent to it.
Here's every way var escapes:
// Case 1: Inside if/else blocks
if (true) {
var role = "admin"; // escapes the if block!
}
console.log(role); // "admin" - no error, var leaked out
//
// Case 2: Inside for/while loops
for (var i = 0; i < 3; i++) {
// loop body
}
console.log(i); // 3 - the loop counter leaked into the outer scope!
//
// Case 3: Inside standalone {} blocks
{
var temp = 42;
}
console.log(temp); // 42 - plain curly braces do nothing to contain var
//
// Case 4: Top-level var in browsers attaches to window
var appName = "MyStore";
console.log(window.appName); // "MyStore" - it's on the global window object
//
// Case 5: Forgetting var/let/const entirely (non-strict mode)
// NOTE: JS does NOT add 'var' here. Instead, it creates an implicit
// property directly on the global window object.
function setScore() {
score = 100; // No let, no const, no var - this becomes a global!
}
setScore();
console.log(window.score); // 100 - accidental global created from inside a function
// In strict mode ("use strict"), this throws: ReferenceError: score is not definedCase 5 is the sneakiest one. You're inside a function, you forget to write let, and JavaScript silently creates a global variable. In strict mode, it throws an error instead. That's one reason strict mode exists.
Where var IS Contained
var is properly contained by function boundaries. Any function - declaration, expression, or arrow function - creates a scope that var cannot escape.
// Case 1: Regular function declarations
function calculateTotal() {
var total = 500; // TRAPPED inside calculateTotal
}
calculateTotal();
console.log(total); // ReferenceError: total is not defined
//
// Case 2: Arrow functions
const processData = () => {
var status = "completed"; // TRAPPED inside the arrow function
};
processData();
console.log(status); // ReferenceError: status is not defined
//
// Case 3: ES Modules (files using import/export syntax, or .mjs files)
// var at the top level of a module is NOT added to window.
// It's isolated to that module file.
var internalRate = 0.05; // In a module: scoped to this file onlyThe one rule for var: functions trap it. Blocks don't.
How to Never Deal with var Leakage Again
Four techniques. Use them in order of preference:
// Technique 1 - Use let and const (always do this in modern JS)
// They're block-scoped and respect any {}
if (true) {
let safeVar = "I am block-scoped";
const alsoSafe = "Me too";
}
// console.log(safeVar); // ReferenceError - dies at the closing }
//
// Technique 2 - IIFE (Immediately Invoked Function Expression)
// Used for legacy code that must use var.
// Wrapping code in a function creates a function scope that traps var.
// The () at the end immediately calls the function after defining it.
(function() {
var legacyPrivate = "safe inside the IIFE";
})();
// console.log(legacyPrivate); // ReferenceError - trapped by the function boundary
//
// Technique 3 - ES Modules
// Any file using import/export gets file-level module scope.
// Variables don't attach to window, even with var.
//
// Technique 4 - Strict Mode
"use strict";
function testStrict() {
// undeclaredVar = 99; // Would throw ReferenceError instead of creating a global
}In new code, just use let and const. The IIFE technique exists because JavaScript has millions of older codebases that can't be immediately rewritten. If you encounter old code, you'll see IIFEs everywhere. Now you know why.
How JavaScript Finds a Variable When You Use It
The Scope Chain - Walking Up the Ladder
When JavaScript hits a variable name in your code, it doesn't know where that variable is immediately. It has to find it. Here's the exact search order:
- Look in the current local scope first.
- If not found, step UP to the parent scope.
- Keep stepping up through each parent scope.
- If the variable isn't found in the global scope either, throw a
ReferenceError.
const country = "India"; // 3. Global scope - found last if needed
//
function outer() {
const city = "Delhi"; // 2. outer() function scope
//
function inner() {
const area = "Connaught Place"; // 1. inner() own scope - found first
//
console.log(area); // Found right here in inner's scope
console.log(city); // Not local - steps UP to outer's scope, finds it
console.log(country); // Not in outer - steps UP to global scope, finds it
}
//
inner();
}
//
outer();Two rules that never change:
The chain only goes up. An inner function can see variables from outer scopes, but an outer scope cannot look DOWN into inner scopes. Ever.
Variable shadowing. If you declare a variable with the same name as one in an outer scope, the inner one takes over within its own scope. The outer one still exists - it's just hidden from that point inward.
const username = "Alice"; // global
//
function showUser() {
const username = "Bob"; // shadows the global 'username' within this function
console.log(username); // "Bob" - inner wins
}
//
showUser(); // "Bob"
console.log(username); // "Alice" - global was never changed, just shadowedShadowing isn't an error. But it causes confusing bugs when you shadow a variable by accident. You meant to use the outer one, but you accidentally declared a new inner one with the same name. JavaScript doesn't warn you.
The Trick JavaScript Plays Before Your Code Runs
Hoisting - Declarations First, Assignments Later
JavaScript runs your code in two phases. In the first phase (compilation), the engine scans the file and registers all variable and function declarations. In the second phase (execution), it actually runs the code line by line.
This is why some things behave in ways that feel backwards.
// 1. var is hoisted AND initialized to undefined
// The declaration 'var x' is moved to the top by the engine.
// The ASSIGNMENT '= 10' stays in place.
console.log(x); // undefined - NOT a ReferenceError
var x = 10;
console.log(x); // 10
//
// 2. Function DECLARATIONS are fully hoisted - name AND body
// You can call them before their line in the file.
greet("Raj"); // "Hello Raj" - works, even though the definition comes later!
//
function greet(name) {
return "Hello " + name;
}
//
// 3. let and const are hoisted but blocked by the TDZ (more on that below)
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 10;
//
// 4. Function EXPRESSIONS assigned to let/const - NOT hoisted
sayHi(); // ReferenceError: Cannot access 'sayHi' before initialization
const sayHi = () => "Hi!";The var case trips people up badly. console.log(x) returns undefined instead of throwing an error. It looks like the code is working. It isn't. The declaration was hoisted, but the value 10 hadn't been assigned yet. You're reading an uninitialized variable, and JavaScript silently hands you undefined instead of stopping you.
let and const refuse to do that. They throw an error instead. That's a feature, not a limitation.
The function declaration case is one place hoisting actually helps you. You can organize code with function definitions at the bottom of a file and the calls at the top, and it works perfectly.
Function expressions don't get the same treatment. const sayHi = () => "Hi!" - the sayHi variable is hoisted (so JavaScript knows the name exists), but the arrow function body is not assigned until that line executes.
The Temporal Dead Zone - The Error You'll See More Than Once
The Temporal Dead Zone (TDZ) is the stretch of code between the start of a block and the actual declaration line of a let or const variable.
During that stretch, the variable exists in memory (the engine registered it during the compilation phase), but it's completely off-limits. Any attempt to read or write it throws a ReferenceError.
// ─────────────────────────────────────────────────────────
// TDZ STARTS HERE for 'score'
// score is known to exist (hoisted), but it's in the dead zone.
// ─────────────────────────────────────────────────────────
//
console.log(score); // ReferenceError: Cannot access 'score' before initialization
// NOT "score is not defined" - JavaScript KNOWS it exists,
// it just refuses to let you touch it yet.
//
let score = 100; // TDZ ENDS HERE - score is now initialized
//
// ─────────────────────────────────────────────────────────
console.log(score); // 100 - fully accessible nowThe error message "Cannot access before initialization" is specific. It's different from "X is not defined." When you see "not defined," the variable genuinely doesn't exist in scope. When you see "before initialization," it exists but is still in the dead zone.
The TDZ exists for a real reason. Without it, let and const would behave like var and silently give you undefined. The TDZ forces you to declare variables before you use them, which catches entire categories of bugs that var would let slide.
When a Function Refuses to Forget
What a Closure Actually Is
Every single function constructed in JavaScript automatically receives an associated closure that binds it to its surrounding lexical environment the exact moment it is created. It does not matter if the function is at the top level or deeply nested; the engine universally attaches this hidden link.
The critical nuance is that while the engine attaches closures universally, developers typically only observe their impact when nested functions act as an anchor. When an inner function is returned to the outside world, it serves as a live anchor, preventing the JavaScript garbage collector from destroying those outer variables because the inner function still needs them.
The inner function gets access to the outer function's variables: not a copy of those variables, but an ongoing live connection to the actual memory where they live.
function makeCounter() {
let count = 0; // lives in makeCounter's scope
//
return function() { // this inner function is returned to the outside world
count++; // accesses 'count' from makeCounter's scope
return count;
};
}
//
// makeCounter() runs and returns. It's done.
// But 'count' is NOT destroyed - the inner function still references it.
const counter = makeCounter();
//
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
console.log(counter()); // 4
//
// Create a completely new, independent counter
const counter2 = makeCounter();
console.log(counter2()); // 1 - its own private count, starts from 0
console.log(counter()); // 5 - counter still has its own count, unaffectedcounter and counter2 don't share a count. Each call to makeCounter() creates a brand new execution environment with its own count variable in memory.
Under the Hood - Why the Variable Doesn't Disappear
When JavaScript executes a function, it creates a Lexical Environment - a memory record that holds all the local variables for that function call.
When the inner function is created inside makeCounter(), JavaScript attaches a hidden property to that function object. This hidden property holds a reference (a pointer) to makeCounter's Lexical Environment in heap memory. The inner function doesn't copy the value of count. It holds a live link to the memory slot where count lives.
Normally, when makeCounter() finishes and returns, JavaScript's garbage collector would clean up the Lexical Environment - clearing count from memory. But it can't. There's an active reference chain keeping that memory slot alive:
// Reference chain that keeps 'count' alive in heap memory:
//
// counter variable
// → inner function object
// → [[Environment]] (hidden property)
// → makeCounter's Lexical Environment { count: 0 }
//
// The garbage collector cannot collect { count: 0 }
// because 'counter' is still pointing at the chain.This is why mutations persist across calls. When counter() runs and does count++, it follows the reference chain to the actual memory slot, reads the current number, and writes the incremented value back. The next time counter() runs, it follows the same chain and finds the updated value.
When you call makeCounter() a second time, JavaScript allocates a completely new Lexical Environment in a different spot in heap memory. counter2 ends up with a reference chain that points to that separate slot. The two counters are physically stored in different memory locations.
What You Can Actually Build with Closures
Data Privacy Without a Class
Closures were JavaScript's main way of creating private state for years, before the language got class syntax. The pattern is called a factory function: a function that creates and returns an object, where the returned object's methods all share access to a private variable through closure.
function createBankAccount(initialBalance) {
// 'balance' is private - it exists only in this closure.
// No code outside this function can directly access it.
let balance = initialBalance;
//
return {
deposit(amount) {
if (amount > 0) balance += amount;
return balance;
},
withdraw(amount) {
if (amount > 0 && amount <= balance) balance -= amount;
return balance;
},
getBalance() {
return balance;
}
};
}
//
const account = createBankAccount(1000);
console.log(account.getBalance()); // 1000
//
account.deposit(500);
console.log(account.getBalance()); // 1500
//
account.withdraw(200);
console.log(account.getBalance()); // 1300
//
// Trying to access balance directly:
console.log(account.balance); // undefined - it's not a property on the objectdeposit, withdraw, and getBalance all form closures over the same balance variable. They can mutate it freely. Code outside the factory cannot touch it at all - there's no property path that reaches it.
Each call to createBankAccount() creates an independent closure with its own balance:
const account2 = createBankAccount(500);
account2.deposit(100);
console.log(account2.getBalance()); // 600
console.log(account.getBalance()); // 1300 - completely separate, unaffectedModern JavaScript gives you class #privateField syntax for private state. But the closure factory pattern still appears in interview questions, older codebases, and functional JavaScript patterns. Knowing it makes you better at reading other people's code.
The Bug That Shows Up in Every Job Interview
The Closure-in-Loop Problem
This is probably the most common "predict the output" question in JavaScript interviews. If someone shows you this code and asks what it prints, you should know the answer instantly.
// THE BUG - using var in a loop with setTimeout
for (var i = 0; i < 3; i++) {
// 'i' is function-scoped (or global-scoped here).
// There is only ONE 'i' variable in memory for the entire loop.
setTimeout(function() {
console.log(i); // what do you expect this to print?
}, 100);
}
//
// Expected: 0, 1, 2
// Actual: 3, 3, 3The reason is exactly what you've learned in this article.
var is function-scoped. The for loop's {} doesn't contain it. So there's only one i in memory for the whole loop. All three setTimeout callbacks form closures over that same single i variable.
The for loop is synchronous. It runs to completion in less than a millisecond. By the time the 100ms timers fire and the callbacks actually execute, the loop is long finished and i === 3. Each callback follows its reference to the one shared i slot and reads 3.
Three callbacks. One shared memory slot. One value: 3.
Why let Solves It and var Never Will
// FIX 1 - Use let
for (let i = 0; i < 3; i++) {
// let in a for loop header creates a FRESH memory slot per iteration.
// Iteration 0 gets its own { i: 0 }
// Iteration 1 gets its own { i: 1 }
// Iteration 2 gets its own { i: 2 }
setTimeout(function() {
console.log(i);
}, 100);
}
// Output: 0, 1, 2 - each callback has its own private i
//
// FIX 2 - IIFE (for legacy code that must use var)
for (var i = 0; i < 3; i++) {
// Each iteration immediately calls this function,
// passing the CURRENT VALUE of i as an argument.
// The argument gets copied into 'captured' - a new
// function-scoped variable.
// The callback closes over 'captured', not over the shared 'i'.
(function(captured) {
setTimeout(function() {
console.log(captured);
}, 100);
})(i);
}
// Output: 0, 1, 2 - each IIFE call created its own scope
// with its own copyWhen let is used in a for loop header, the JavaScript engine allocates a brand new Lexical Environment record for each iteration. The three callbacks end up pointing to three completely separate memory slots: one holding 0, one holding 1, one holding 2. When they fire later, each reads its own private slot.
WARNING: This "fresh slot per iteration" magic is a special engine behavior that only activates when
letis declared directly inside theforloop header. If you declareletoutside the loop, the bug comes right back.
// THE SCOPE TRAP WITH LET
// Declaring 'let' outside the for loop header creates only ONE
// memory slot in the outer lexical environment.
// The loop iterations do not get fresh bindings, causing the
// closures to share a single reference just like 'var'.
let j;
for (j = 0; j < 3; j++) {
setTimeout(function() {
// Reads the final mutated value of the shared memory slot.
console.log(j);
}, 100);
}
// Expected: 0, 1, 2
// Actual: 3, 3, 3The IIFE approach works differently. It copies the current primitive value of i into a function parameter called captured. Primitive numbers are copied by value when passed to functions. So each IIFE call gets its own captured variable holding the iteration's number at that moment.
This isn't just about setTimeout. Any callback that runs after a delay, any event listener, any Promise - same behavior. If you close over a shared var in a loop, you'll read the final value every time.
Before You Close This Tab
Scope is the rule. Closures are what happens when functions refuse to let go of their rules.
Use const and let everywhere. The var problems in this article aren't theoretical. They happen in real code, often in code you didn't write, and they're subtle enough to burn an hour of debugging if you don't know what to look for.
The closure pattern shows up in event handlers, timer callbacks, module patterns, and factory functions. Once you start seeing it, you find it everywhere.
And that loop bug - when an interviewer shows you for (var i = 0; i < 3; i++) with setTimeout and asks what prints: you know it's 3, 3, 3. You know it's because var shares a single memory slot across all iterations. You know let fixes it by creating a fresh slot per iteration.
That's not just an interview answer. That's a real understanding of how JavaScript remembers things.