JavaScript Closures Explained with Real Examples

JavaScript Closures Explained with Real Examples

Variables Die When Functions End. Except When They Don’t.

Here’s something that should bother you. A function runs, finishes, returns its value, and gets wiped from memory. Every variable inside it? Gone. Garbage collected. Dead. JavaScript’s rules about scope say so.

And yet… a variable can survive. It can linger after its parent function has ended, accessible to code that shouldn’t logically be able to reach it. Not through some hack or workaround — through a feature baked into the language from day one.

I want to walk you through how that works, because closures in JavaScript confused me for about a year before something clicked. Maybe you’re in that same stretch right now. Maybe you’ve read three blog posts and they all used the same “counter” example without telling you why it matters. Let me try a different route.

We’ll follow a story — a small project that grows — and I’ll point out closures as they show up naturally. Not as some abstract computer science concept. As a tool you reach for when other options feel clunky. By the end, you’ll probably wonder how you ever missed them, since they’ve been sitting inside practically every JavaScript program you’ve written.

A Greeting Card Machine (Where It All Starts)

Picture building a greeting card app. Nothing fancy — users pick a greeting style, and the app generates personalized messages. Your first instinct might be to write one big function that takes everything as arguments. Greeting, name, format, language. But your colleague Priya suggests something different: why not build a machine that remembers the greeting part, so you only pass in the name later?

She writes something like this on the whiteboard:


// When a function is created, it captures a reference to its
// surrounding scope. This captured scope persists even after
// the outer function returns.

function createGreeter(greeting) {
    // 'greeting' is captured by the inner function
    return function(name) {
        // This inner function is a closure. It "closes over"
        // the 'greeting' variable from the outer scope.
        return `${greeting}, ${name}!`;
    };
}

const sayHello = createGreeter("Hello");
const sayHola = createGreeter("Hola");

console.log(sayHello("Alice")); // "Hello, Alice!"
console.log(sayHola("Bob"));    // "Hola, Bob!"

// createGreeter has already returned, but the inner function
// still has access to 'greeting'. That is a closure.

Look at what happened. createGreeter("Hello") ran, finished, returned. Normally, greeting should be gone. But sayHello still knows it’s “Hello.” It carries that variable around like a note tucked in its pocket. sayHola carries its own note that says “Hola.” They don’t share. Each call to createGreeter builds a fresh scope, and the returned function clings to that scope forever.

Priya grins and says, “That’s a closure.” And she’s right. When an inner function reaches outside itself to grab a variable from its parent’s scope — and that parent has already returned — you’re looking at a closure in action.

Worth pausing on something. You might hear people say “every function in JavaScript is a closure.” Technically true, since every function captures its surrounding scope. But the concept only gets interesting — only becomes a pattern you’d name and think about — when the inner function outlives the outer one. A function that runs and finishes inside its parent? Sure, it can access the parent’s variables, but nothing surprising happens. Closures get their reputation from that survival trick: the parent is gone, but the child still remembers.

Why the Variable Doesn’t Die

So how does JavaScript pull this off? It comes down to something called lexical scope. Sounds intimidating, but it’s actually pretty straightforward.

When you write a function inside another function, JavaScript locks in the variable access at write-time. Not when the function runs. Not when someone calls it three minutes later. At the moment you write the code, the engine already knows which variables the inner function can see. It creates a reference chain back to the enclosing scope.

Normally, when a function finishes executing, its local variables get cleaned up. But if some inner function still holds a reference to those variables? JavaScript keeps them alive. Won’t garbage collect them. Can’t, really — something still points to them.

I think of it like a library book. You check it out, the library can’t destroy it. Even if the library closes (the outer function returns), your checkout slip (the closure) keeps the book (the variable) in existence. Probably not a perfect analogy, but it helped me get unstuck back in 2019 when I was wrestling with scope chains for the first time.

Priya’s App Gets Stateful

Back to the greeting card project. A week in, the product manager wants analytics. How many cards has each greeting style generated? Priya could store that count in a global variable, but she’s seen what happens to global variables on a team of five developers. Chaos. Naming collisions. Bugs at 2 AM.

Instead, she reaches for closures again:


function createCounter(initialValue = 0) {
    let count = initialValue;

    return {
        increment() {
            count += 1;
            return count;
        },
        decrement() {
            count -= 1;
            return count;
        },
        reset() {
            count = initialValue;
            return count;
        },
        getCount() {
            return count;
        }
    };
}

const counter = createCounter(10);
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.reset());     // 10

// 'count' is completely private. There is no way to access
// or modify it except through the returned methods.
console.log(counter.count); // undefined

// Each counter is independent
const counterA = createCounter();
const counterB = createCounter(100);
counterA.increment();
counterA.increment();
console.log(counterA.getCount()); // 2
console.log(counterB.getCount()); // 100

Notice what’s happening. count lives inside createCounter. Once that function returns, nobody from the outside can touch count directly. Try counter.count and you get undefined. The only way to read or change it is through the methods — increment, decrement, reset, getCount. Each of those methods is a closure, reaching back into the scope where count was born.

Priya now has private state without writing a single class. No this keyword confusion. No prototype chain. Just functions and scope. She makes one counter per greeting style, and each counter minds its own business.

If you’re coming from Java or C#, this might feel strange. Those languages use classes and access modifiers — private, protected, public — to control who touches what. JavaScript took a different road for most of its history. Closures were the privacy mechanism. And honestly, for small to medium modules, they’re lighter and faster to write than a full class with private fields. Not always the right choice, but often the simplest one.

Factories: Closures That Build Customized Tools

A month into the project, the team needs to handle pricing for greeting cards across different countries. India has 18% GST. The US has state-dependent sales tax. UK has 20% VAT. Writing a separate tax function for each country would be tedious and brittle.

Closures offer a cleaner path. You build a factory — a function that produces other functions, each pre-loaded with its own configuration:


// Tax calculator factory
function createTaxCalculator(taxRate, currency = "$") {
    return function(amount) {
        const tax = amount * (taxRate / 100);
        const total = amount + tax;
        return {
            subtotal: `${currency}${amount.toFixed(2)}`,
            tax: `${currency}${tax.toFixed(2)}`,
            total: `${currency}${total.toFixed(2)}`,
            rate: `${taxRate}%`
        };
    };
}

const calcUSA = createTaxCalculator(8.875, "$");
const calcUK = createTaxCalculator(20, "£");
const calcJapan = createTaxCalculator(10, "¥");

console.log(calcUSA(100));
// { subtotal: "$100.00", tax: "$8.88", total: "$108.88", rate: "8.875%" }

console.log(calcUK(100));
// { subtotal: "£100.00", tax: "£20.00", total: "£120.00", rate: "20%" }

// URL builder factory
function createAPIClient(baseURL) {
    const headers = { "Content-Type": "application/json" };

    return {
        get(endpoint) {
            return fetch(`${baseURL}${endpoint}`, { headers });
        },
        post(endpoint, data) {
            return fetch(`${baseURL}${endpoint}`, {
                method: "POST",
                headers,
                body: JSON.stringify(data),
            });
        },
        setHeader(key, value) {
            headers[key] = value;
        }
    };
}

const api = createAPIClient("https://api.example.com");
// api.get("/users")    -- fetches https://api.example.com/users
// api.post("/tasks", { title: "New task" })
api.setHeader("Authorization", "Bearer token123");

Each calculator remembers its own taxRate and currency. calcUSA doesn’t know about calcUK. They’re independent closures with independent scopes. And the API client? Same idea. baseURL and headers get captured by the returned object’s methods. You configure once, then use the slim interface everywhere else in your code.

Factory functions like these show up constantly in production codebases. If you’ve used Express middleware, Redux thunks, or React hooks — you’ve been relying on closures without maybe realizing it. That’s sort of the sneaky beauty of this feature. It’s everywhere, hiding in plain sight.

For Priya’s team, the API client was a turning point. Before, they’d been passing baseURL and headers into every fetch call across 30+ files. One typo in the base URL and half the app broke. With the factory pattern, configuration lives in one place and the rest of the codebase just calls api.get("/endpoint"). Cleaner code, fewer bugs, and the closure does all the remembering.

When Priya’s Users Start Typing Too Fast

The greeting card app now has a search bar. Users type a name, and the app searches a database. Problem is, every keystroke fires a search request. Type “Ankit” and you’ve just sent five API calls: A, An, Ank, Anki, Ankit. The backend team is not happy.

Priya needs debouncing — a way to wait until the user stops typing before sending the request. And here’s where closures really flex in event-driven code. Event handlers and callbacks are probably the most common place you’ll encounter closures in JavaScript, even if nobody points them out.


// Debounce function: delays execution until user stops typing
function debounce(fn, delay) {
    let timeoutId = null;

    return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

// Usage:
// const handleSearch = debounce((query) => {
//     console.log("Searching for:", query);
//     fetchSearchResults(query);
// }, 300);
// searchInput.addEventListener("input", (e) => handleSearch(e.target.value));

// Rate limiter: ensures a function runs at most once per interval
function rateLimit(fn, interval) {
    let lastCallTime = 0;

    return function(...args) {
        const now = Date.now();
        if (now - lastCallTime >= interval) {
            lastCallTime = now;
            return fn.apply(this, args);
        }
    };
}

// Memoization: caches function results
function memoize(fn) {
    const cache = new Map();

    return function(...args) {
        const key = JSON.stringify(args);
        if (cache.has(key)) {
            console.log(`Cache hit for ${key}`);
            return cache.get(key);
        }
        const result = fn.apply(this, args);
        cache.set(key, result);
        return result;
    };
}

const expensiveCalc = memoize((n) => {
    console.log(`Computing fibonacci(${n})...`);
    if (n <= 1) return n;
    return expensiveCalc(n - 1) + expensiveCalc(n - 2);
});

console.log(expensiveCalc(10)); // Computes and caches
console.log(expensiveCalc(10)); // Cache hit!

Let's unpack the debounce function, since it's a beautiful little closure puzzle. When you call debounce(fn, delay), it creates a scope with timeoutId sitting inside. Every time the returned function fires (on each keystroke), it clears the old timeout and sets a new one. But here's the closure magic: timeoutId persists between calls. It's not re-declared each time the inner function runs. It lives in the parent's scope, kept alive by the closure.

Rate limiting works the same way. lastCallTime survives between invocations because the returned function closes over it. And memoization? The cache Map sticks around forever, growing with each new unique argument set. Without closures, you'd need global variables or class instances to hold all that state. Closures give you a cleaner option.

Private Vaults: Why Closures Beat Global Variables

By now Priya's app is growing. She builds a payment module, and the team lead has one rule: balance and transaction data must be untouchable from outside the module. In 2024, we've got private class fields with the # syntax. But closures were doing data privacy long before that existed, and they're still lighter-weight for many situations.


function createBankAccount(owner, initialBalance = 0) {
    let balance = initialBalance;
    const transactions = [];

    function recordTransaction(type, amount) {
        transactions.push({
            type,
            amount,
            balance,
            timestamp: new Date().toISOString(),
        });
    }

    return {
        getOwner() {
            return owner;
        },
        getBalance() {
            return balance;
        },
        deposit(amount) {
            if (amount <= 0) throw new Error("Deposit must be positive");
            balance += amount;
            recordTransaction("deposit", amount);
            return balance;
        },
        withdraw(amount) {
            if (amount <= 0) throw new Error("Withdrawal must be positive");
            if (amount > balance) throw new Error("Insufficient funds");
            balance -= amount;
            recordTransaction("withdrawal", amount);
            return balance;
        },
        getStatement() {
            return [...transactions]; // Return a copy
        }
    };
}

const account = createBankAccount("Alice", 1000);
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance());   // 1300
console.log(account.getStatement()); // [{...}, {...}]

// Cannot access internals directly:
console.log(account.balance);      // undefined
console.log(account.transactions); // undefined

balance and transactions are locked away. Not hidden behind an underscore convention or a "please don't touch" comment -- genuinely unreachable from outside. The only gates in or out are the methods returned by createBankAccount. Each method is a closure that remembers the private scope. recordTransaction itself is also a closure, but it's not even exposed to the outside world. It's a private helper that only deposit and withdraw can use.

If you've ever worked on a team where someone accidentally mutated shared state and caused a production bug at 3 AM -- you understand why data privacy matters. Closures don't just enable it. They enforce it. No discipline required from fellow developers. The language itself won't let outsiders in.

Priya actually caught a bug this way during code review. A junior developer had tried to write account.balance = 0 to reset an account. Didn't work. Didn't silently corrupt data either. It just set a new property on the object that had nothing to do with the actual closed-over balance variable. The real balance stayed untouched. In a codebase without closures, that same mistake might have zeroed out someone's actual account balance. Scary difference.

The Trap Everyone Falls Into (At Least Once)

Alright. I'd be doing you a disservice if I painted closures as pure sunshine. There's a trap, and I'd guess roughly 80% of JavaScript developers have stumbled into it during their first year. It involves loops.

Priya discovers it when she tries to set up three delayed log messages:


// PITFALL: Loop variable capture with var
// The classic closure-in-a-loop bug
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log("var:", i), 100);
}
// Prints: var: 3, var: 3, var: 3 (not 0, 1, 2!)
// All closures share the same 'i' variable, which is 3 after the loop.

// FIX 1: Use let (block-scoped, creates new binding per iteration)
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log("let:", i), 100);
}
// Prints: let: 0, let: 1, let: 2

// FIX 2: IIFE (Immediately Invoked Function Expression)
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(() => console.log("iife:", j), 100);
    })(i);
}
// Prints: iife: 0, iife: 1, iife: 2

// PITFALL: Memory leaks from unintended retention
function createHandler() {
    const hugeData = new Array(1000000).fill("x"); // 1M elements
    return function() {
        // If this closure references hugeData, it cannot be garbage collected
        console.log(hugeData.length);
    };
}
// Solution: only close over what you need
function createHandlerFixed() {
    const hugeData = new Array(1000000).fill("x");
    const length = hugeData.length; // Extract what you need
    return function() {
        console.log(length); // hugeData can now be garbage collected
    };
}

What went wrong with var? Here's the key: var is function-scoped, not block-scoped. So there's only one i for the entire loop. All three setTimeout callbacks close over that same single i. By the time they actually run (100 milliseconds later), the loop has finished and i sits at 3. Every callback reads 3.

Switching to let fixes it because let is block-scoped. Each trip through the loop creates a fresh i, and each closure captures its own copy. The IIFE fix does the same thing manually -- it creates a new function scope per iteration, giving each callback its own private j. Before 2015, when let didn't exist yet, IIFEs were the only escape hatch. You'll still find them in older codebases and in interview questions.

And that second pitfall -- memory leaks -- is subtler. If your closure captures a reference to a massive array but only needs its length, the entire array stays in memory for as long as the closure exists. Extracting just the data you need into a smaller variable lets the garbage collector do its job. I've seen production apps leak megabytes because of careless closures holding onto DOM nodes or large API responses. It's worth being mindful about.

Where You've Already Been Using Closures

Before we wrap up, let me point out something you might not have noticed. If you've written any of these, you've already used closures:

Array methods with context. When you pass a callback to .map(), .filter(), or .reduce() and that callback uses a variable from outside itself -- closure. Something like const threshold = 10; arr.filter(x => x > threshold). That arrow function closes over threshold.

Event listeners. An addEventListener callback that references a variable from the enclosing scope? Closure. The event might fire minutes after the surrounding code ran, but the callback still has access.

Promises and async/await. When you .then() a promise and the handler uses variables from the surrounding function -- closure again. The handler runs later, asynchronously, but it remembers its birth scope.

React components. Every single useState hook relies on closures under the hood. The setter function returned by useState captures the component's state through a closure mechanism. If you've ever hit a "stale closure" bug in React where your effect reads an old state value -- now you know what's happening. The closure captured a snapshot of state from an earlier render.

Express middleware. Middleware factories that take a config object and return a handler? Factory function pattern. Closures.

You've been swimming in closures this whole time. Naming the pattern just helps you debug it when things go sideways.

Building Your Own Closure Toolkit

Let me give you a few patterns worth keeping in your back pocket. These aren't exotic -- you'll find them in real codebases at companies of all sizes.

Once function. Want a function that only runs the first time it's called, then returns the cached result forever after? Closure makes it trivial.


function once(fn) {
    let called = false;
    let result;
    return function(...args) {
        if (!called) {
            called = true;
            result = fn.apply(this, args);
        }
        return result;
    };
}

const initialize = once(() => {
    console.log("Running expensive setup...");
    return { ready: true };
});

initialize(); // "Running expensive setup..." -> { ready: true }
initialize(); // No log, returns cached { ready: true }
initialize(); // Same. Will never run the setup again.

called and result live in the closure. First invocation flips called to true and stores the return value. Every subsequent call skips the function entirely. Database connections, config file parsing, heavy initializations -- the once pattern shows up all the time.

Logger with prefix. When you're debugging across modules, knowing which module logged a message saves serious headaches.


function createLogger(prefix) {
    return {
        log: (...args) => console.log(`[${prefix}]`, ...args),
        warn: (...args) => console.warn(`[${prefix}]`, ...args),
        error: (...args) => console.error(`[${prefix}]`, ...args),
    };
}

const authLogger = createLogger("AUTH");
const dbLogger = createLogger("DB");

authLogger.log("User logged in");   // [AUTH] User logged in
dbLogger.error("Connection failed"); // [DB] Connection failed

Dead simple. prefix gets captured, every log call prepends it. Each logger is independent. You could create fifty of these and they'd never interfere with each other.

A Mental Model That Actually Sticks

Forget the textbook stuff for a second. Here's how I'd explain closures to a friend over coffee.

Every function in JavaScript is born with a backpack. When the function is created, JavaScript stuffs into that backpack all the variables from the surrounding area that the function might need. Even after the surrounding function finishes and its local variables would normally get destroyed, the backpack keeps copies alive. The inner function carries its backpack wherever it goes -- passed as a callback, returned from a factory, stored in an array, whatever. As long as that function exists, its backpack exists.

Two functions created in the same outer function get different backpacks, even though they were packed in the same room. That's why counterA and counterB earlier didn't share state.

When the backpack holds something huge (like a million-element array) and you only need a small piece of it, you should probably pull out just what you need and let the big thing get thrown away. Otherwise you're hauling unnecessary weight.

And when a loop creates functions using var, all those functions end up sharing one backpack (because var doesn't create a new pocket per iteration). Switching to let gives each function its own pocket within the backpack.

Rough around the edges? Sure. But I've found it more useful than any formal definition.

Variables That Refuse to Die

We started with a contradiction. Variables die when functions end -- except when they don't. Now you know the "except." A closure keeps those variables breathing, long after their parent scope has returned and been forgotten by the call stack.

Every callback you pass, every factory function you build, every debounce or memoize utility you reach for -- closures are doing the quiet work underneath. They let functions remember where they came from. They make private state possible without classes. They're the reason JavaScript can be both simple and surprisingly powerful.

Priya's greeting card app ships on time. The counters track usage without leaking state. The tax calculators handle five countries without duplicated logic. The debounced search bar doesn't melt the backend. And none of it required a single class declaration.

Variables die when functions end. Except when a closure decides they don't. And honestly? That exception might be the most useful feature JavaScript ever gave us.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.