JavaScript Scope and Closures

Scope determines where variables live. Closures keep them alive. Understanding both is the key to writing predictable JavaScript and avoiding the bugs that confuse even experienced developers.

Intermediate 12 min read 11 examples

Scope Types

Scope is the region of code where a variable can be accessed. JavaScript has three scope levels.

JavaScript
// 1. GLOBAL SCOPE - accessible everywhere
const globalVar = "I am global";
var globalVar2 = "Also global";  // var in global scope -> window.globalVar2

// 2. FUNCTION SCOPE - only inside the function
function outerFn() {
    const funcVar = "I am function-scoped";
    console.log(globalVar); // OK - outer scope visible
    console.log(funcVar);   // OK - same scope
}
// console.log(funcVar); // ReferenceError

// 3. BLOCK SCOPE - let/const inside { } blocks
{
    let blockLet   = "only in this block";
    const blockConst = "also only here";
    var blockVar   = "leaks out! (var is function-scoped)";
}
// console.log(blockLet);   // ReferenceError
// console.log(blockConst); // ReferenceError
console.log(blockVar);      // "leaks out!" (var escapes blocks)

// Block scope in conditionals and loops
if (true) {
    let x = 10;
    const y = 20;
}
// x and y are gone here

for (let i = 0; i < 3; i++) {
    // i exists only inside this loop with let
}

Scope Chain

When JavaScript looks up a variable, it starts in the current scope and walks outward through each containing scope until it finds the variable or reaches global scope.

JavaScript
const level1 = "global";

function outer() {
    const level2 = "outer function";

    function inner() {
        const level3 = "inner function";

        // Scope chain lookup: inner -> outer -> global
        console.log(level3); // found in inner  (own scope)
        console.log(level2); // found in outer  (parent scope)
        console.log(level1); // found in global (grandparent scope)
    }

    inner();
    // console.log(level3); // ReferenceError - cannot look DOWN
}

outer();

// Shadowing: inner scope variable hides outer one
const color = "blue";
function getColor() {
    const color = "red"; // shadows global 'color'
    return color;        // "red" (inner scope wins)
}
console.log(getColor()); // "red"
console.log(color);      // "blue" (global unchanged)

Lexical Scope

JavaScript is lexically scoped: a function's scope is determined by where it is written in the code, not where it is called from.

JavaScript
const env = "production";

function checkEnv() {
    // This function sees 'env' from where it was DEFINED (global)
    // not from where it is called
    console.log(env);
}

function runInTest() {
    const env = "test"; // local variable, not visible to checkEnv
    checkEnv();         // still logs "production"!
}

runInTest(); // "production" - lexical scope, not call-site scope

// This is why callbacks behave predictably
const greetings = ["Hello", "Hi", "Hey"];
const name = "Alice";

greetings.forEach(function(greeting) {
    // 'name' is resolved lexically - where forEach callback was WRITTEN
    console.log(`${greeting}, ${name}`);
});
// Hello, Alice / Hi, Alice / Hey, Alice

Closures

A closure is a function that retains access to its outer scope's variables even after the outer function has returned. It "closes over" the variables it references.

JavaScript
function makeCounter() {
    let count = 0;  // this variable is "closed over"

    return function() {
        count++;
        return count;
    };
}

const counter = makeCounter(); // makeCounter() has returned
console.log(counter()); // 1 - but 'count' still lives!
console.log(counter()); // 2
console.log(counter()); // 3

// Each call creates a separate closure (independent count)
const counterA = makeCounter();
const counterB = makeCounter();
console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1 - separate 'count' variable

// How closures work:
// 1. makeCounter() creates a local 'count'
// 2. The returned function references 'count' from outer scope
// 3. JavaScript keeps 'count' alive because the returned function holds a reference
// 4. 'count' is NOT accessible from outside - true encapsulation

Closure Use Cases

JavaScript
// 1. Data privacy / encapsulation
function createBankAccount(initialBalance) {
    let balance = initialBalance; // private

    return {
        deposit(amount)  { balance += amount; },
        withdraw(amount) { balance = Math.max(0, balance - amount); },
        getBalance()     { return balance; }
    };
}
const account = createBankAccount(1000);
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // 1300
// balance is not accessible from outside

// 2. Factory functions with captured configuration
function createLogger(prefix) {
    return function(msg) {
        console.log(`[${prefix}] ${msg}`);
    };
}
const infoLog  = createLogger("INFO");
const errorLog = createLogger("ERROR");
infoLog("Server started");   // [INFO] Server started
errorLog("Connection failed"); // [ERROR] Connection failed

// 3. Memoization (caching expensive results)
function memoize(fn) {
    const cache = {};
    return function(n) {
        if (n in cache) return cache[n];
        cache[n] = fn(n);
        return cache[n];
    };
}
const fib = memoize(function(n) {
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
});
console.log(fib(40)); // fast (cached intermediate results)

// 4. Loop closure gotcha - with var vs let
// PROBLEM with var (all share same 'i')
const fns = [];
for (var i = 0; i < 3; i++) {
    fns.push(function() { return i; });
}
console.log(fns[0]()); // 3 (not 0!)
console.log(fns[1]()); // 3
console.log(fns[2]()); // 3

// FIXED with let (new binding per iteration)
const fns2 = [];
for (let j = 0; j < 3; j++) {
    fns2.push(function() { return j; });
}
console.log(fns2[0]()); // 0
console.log(fns2[1]()); // 1
console.log(fns2[2]()); // 2
The var Loop Gotcha

The classic closure bug: using var in a loop creates one shared variable. All callbacks close over the same reference and see the final value after the loop. Fix: use let (creates a new binding per iteration) or an IIFE around the callback body. This is one of the most common reasons to prefer let over var.

IIFE - Immediately Invoked Function Expression

An IIFE is a function that is defined and called in a single expression. It creates a private scope that does not leak variables into the surrounding context.

JavaScript
// Basic IIFE syntax
(function() {
    const secret = "only inside here";
    console.log("IIFE ran!");
})();
// console.log(secret); // ReferenceError

// Arrow IIFE
(() => {
    console.log("Arrow IIFE");
})();

// IIFE with return value
const result = (function() {
    const x = 10;
    const y = 20;
    return x + y;
})();
console.log(result); // 30

// IIFE with parameters
(function(name) {
    console.log(`Hello, ${name}!`);
})("Alice"); // "Hello, Alice!"

// Real-world use: avoid polluting global scope in scripts
(function() {
    // All code here is isolated
    const config = { ... };
    const init = () => { ... };
    init();
})();

Module Scope

In ES modules (type="module"), every file has its own module scope. Top-level variables are NOT global - they are local to the module.

JavaScript
// utils.js - module scope
const DB_URL = "mongodb://localhost"; // not global - private to this file
export const API_KEY = "abc123";      // explicitly exported

// app.js - another module
import { API_KEY } from "./utils.js";
// console.log(DB_URL); // ReferenceError - not exported!

// Module scope benefits:
// - No accidental global variable collisions
// - Explicit dependency declaration via imports
// - Each module is strict mode by default
// - IIFEs are not needed in module code

Frequently Asked Questions

JavaScript uses lexical scope (also called static scope): a function's scope is determined by where it is written in the source code, not where it is called from. Dynamic scope (used by some other languages) would determine scope based on the call stack at runtime. Lexical scope is predictable - you can determine what variables a function can access by reading the code without running it.

A closure is created every time a function is created. It is the combination of the function and the references to variables in its outer lexical environment. In practical terms, a closure is most visible when a function is returned from another function - the returned function "closes over" the outer function's variables, keeping them alive even after the outer function has returned.

An IIFE (Immediately Invoked Function Expression) is a function that is defined and called at the same time: (function() { ... })(). It creates a private scope that does not pollute the global namespace. In modern JavaScript with ES modules and block-scoped let/const, IIFEs are less common but still appear in legacy code and when you need immediate execution with encapsulation in a non-module context.

Closures keep outer variables alive as long as the inner function exists. If that inner function is stored in a long-lived reference (like an event listener, a timer, or a global variable), the closed-over variables cannot be garbage collected. To fix: remove event listeners when no longer needed, clear timers, and avoid storing references to large objects in closures when possible.