JavaScript Closures Demystified
Closures are one of the most powerful — and most misunderstood — features of JavaScript. They show up in interview questions, React hooks, event handlers, module patterns, and virtually every non-trivial JavaScript codebase. Yet developers often use them daily without fully understanding what's happening under the hood. In this guide, we'll demystify closures from first principles: what they are, how the JavaScript engine creates them, why they matter, and how to use them confidently without falling into common traps.
A closure is a function that remembers its lexical environment even when executed outside that environment. In other words, a function "closes over" the variables available when it was defined — not when it's called.
Understanding Scope First
To understand closures, you first need a solid grip on how JavaScript handles scope. Scope determines which variables are accessible at any given point in your code. JavaScript uses lexical scoping (also called static scoping), meaning scope is determined by the location of code in the source file — not by the call stack at runtime.
The Three Types of Scope
- Global scope: Variables declared outside any function or block. Accessible everywhere in the program.
- Function scope: Variables declared inside a function with
var,let, orconst. Accessible only within that function. - Block scope: Variables declared with
letorconstinside{}blocks (loops, if statements, etc.).
const globalVar = "I'm global"; // Global scope
function outer() {
const outerVar = "I'm outer"; // Function scope
if (true) {
const blockVar = "I'm block"; // Block scope
console.log(blockVar); // ✓ accessible here
console.log(outerVar); // ✓ accessible here
console.log(globalVar); // ✓ accessible here
}
console.log(outerVar); // ✓ accessible here
// console.log(blockVar); // ✗ ReferenceError — out of block
}
// console.log(outerVar); // ✗ ReferenceError — out of function
The Scope Chain
When JavaScript looks up a variable, it searches the current scope first, then walks up through enclosing scopes until it reaches the global scope. This chain of scopes is called the scope chain. Closures are built on exactly this mechanism.
JavaScript's scope chain — inner scopes can access outer variable environments
What Exactly Is a Closure?
A closure is created every time a function is defined inside another function. The inner function retains a reference to the outer function's variable environment (called a Lexical Environment in the spec), even after the outer function has returned and its execution context has been removed from the call stack.
function makeGreeter(greeting) {
// `greeting` lives in makeGreeter's scope
return function(name) {
// This inner function "closes over" `greeting`
console.log(`${greeting}, ${name}!`);
};
}
const sayHello = makeGreeter("Hello");
const sayHowdy = makeGreeter("Howdy");
// makeGreeter has returned — its execution context is gone
// But the inner function still has access to `greeting`!
sayHello("Alice"); // "Hello, Alice!"
sayHowdy("Bob"); // "Howdy, Bob!"
sayHello("Carol"); // "Hello, Carol!" (greeting is still "Hello")
What makes this remarkable: makeGreeter has finished executing by the time we call sayHello. Normally, you'd expect its local variables to be garbage-collected. But because the returned function holds a reference to greeting, JavaScript keeps that variable alive. Each call to makeGreeter creates a new, independent closure with its own copy of greeting.
Internally, when a function is created, JavaScript attaches a [[Environment]] slot to it pointing to the Lexical Environment where it was defined. When the function executes, it creates a new Lexical Environment whose outer reference is that captured environment — this is the closure chain.
Practical Closure Patterns
Closures aren't just an academic concept — they power some of JavaScript's most common and useful patterns. Let's explore the ones you'll use most often in real codebases.
1. Function Factories
A function factory returns a specialized function configured by arguments passed at creation time. This is closures in their most direct form.
// Multiplier factory
function makeMultiplier(factor) {
return (number) => number * factor;
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
const tenTimes = makeMultiplier(10);
console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(tenTimes(5)); // 50
// Power factory for API endpoints
function makeApiCaller(baseUrl) {
return async function(endpoint, options = {}) {
const response = await fetch(`${baseUrl}${endpoint}`, options);
return response.json();
};
}
const githubApi = makeApiCaller("https://api.github.com");
const myApi = makeApiCaller("https://api.myapp.com/v2");
// Clean, specialized callers — baseUrl is captured
const user = await githubApi("/users/octocat");
const posts = await myApi("/posts");
2. Data Privacy with the Module Pattern
Before ES modules existed, closures were the primary way to create private state in JavaScript. The module pattern uses an IIFE (Immediately Invoked Function Expression) to encapsulate private variables and expose only a public API.
const counter = (() => {
// Private state — not accessible outside
let count = 0;
const history = [];
return {
increment(amount = 1) {
count += amount;
history.push({ action: 'increment', value: amount, result: count });
},
decrement(amount = 1) {
count -= amount;
history.push({ action: 'decrement', value: amount, result: count });
},
reset() {
count = 0;
history.push({ action: 'reset', result: 0 });
},
getCount() { return count; },
getHistory() { return [...history]; } // Return a copy
};
})();
counter.increment(5);
counter.increment(3);
counter.decrement(2);
console.log(counter.getCount()); // 6
console.log(counter.getHistory()); // [{action:'increment',...}, ...]
// Cannot access private state directly
console.log(counter.count); // undefined
console.log(counter.history); // undefined
3. Memoization
Closures make it straightforward to build memoization — caching expensive function results so repeated calls with the same arguments are instantaneous.
function memoize(fn) {
const cache = new Map(); // Closed over by the returned function
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;
};
}
// Expensive recursive fibonacci — O(2^n) without memoization
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
const memoFib = memoize(fib);
console.time('first call');
memoFib(40); // Computes: ~1100ms
console.timeEnd('first call');
console.time('second call');
memoFib(40); // Cache hit: <1ms
console.timeEnd('second call');
4. Partial Application and Currying
Closures enable partial application — fixing some arguments of a function now and providing the rest later. Currying takes this further by transforming an n-argument function into n single-argument functions.
// Partial application
function partial(fn, ...presetArgs) {
return function(...laterArgs) {
return fn(...presetArgs, ...laterArgs);
};
}
function add(a, b, c) {
return a + b + c;
}
const add10 = partial(add, 10);
const add10and20 = partial(add, 10, 20);
console.log(add10(5, 3)); // 18
console.log(add10and20(7)); // 37
// Currying
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return function(...moreArgs) {
return curried(...args, ...moreArgs);
};
};
}
const curriedAdd = curry((a, b, c) => a + b + c);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
The Classic Loop Gotcha
The most infamous closure bug involves loops and asynchronous callbacks. Understanding this pitfall — and its solutions — is essential for every JavaScript developer.
// BUG: All callbacks print 5, not 0-4
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // Prints 5, 5, 5, 5, 5 !!
}, i * 100);
}
// Why? All five closures share the SAME `i` variable.
// By the time any callback fires, the loop has finished
// and i === 5. var has function scope, not block scope.
The bug exists because var is function-scoped — all five iterations of the loop share the same i binding. The closures don't capture the value of i at the time of creation; they capture the variable itself. By the time the timeouts fire, i has been incremented to 5.
There are three clean solutions, each teaching something different about closures:
Simplest modern fix: Replace var with let. Block-scoped let creates a new binding per iteration.
// let creates a new binding for each loop iteration
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // 0, 1, 2, 3, 4 ✓
}, i * 100);
}
// Each iteration has its own `i` — a genuinely separate variable.
// Under the hood, the engine re-initializes `i` each time
// and carries the previous value forward.
Classic ES5 fix: Wrap the callback in an IIFE to create a new scope per iteration, capturing the current value of i as a parameter.
for (var i = 0; i < 5; i++) {
(function(capturedI) {
setTimeout(function() {
console.log(capturedI); // 0, 1, 2, 3, 4 ✓
}, capturedI * 100);
})(i); // Immediately pass current i as argument
}
// The IIFE creates a new scope each iteration.
// `capturedI` is a fresh parameter binding with the current value.
// The inner closure captures capturedI, not the shared i.
Functional style: Use .bind() or pass i as an extra argument to setTimeout.
// Using Function.bind to pre-bind arguments
for (var i = 0; i < 5; i++) {
setTimeout(console.log.bind(null, i), i * 100); // 0,1,2,3,4 ✓
}
// Or pass extra args to setTimeout (less known but valid)
for (var i = 0; i < 5; i++) {
setTimeout(function(value) {
console.log(value); // 0, 1, 2, 3, 4 ✓
}, i * 100, i); // Third arg is forwarded to callback
}
Closures and Memory Management
Because closures keep variables alive beyond the lifetime of their enclosing function, they can cause memory leaks if misused. Understanding when variables are eligible for garbage collection is critical for building performant applications.
// POTENTIAL MEMORY LEAK: Closure retains large data
function attachHandler() {
const largeData = new Array(1_000_000).fill('x'); // ~8MB
document.getElementById('btn').addEventListener('click', function() {
// Even though we never use largeData here,
// the closure keeps it in memory as long as
// the event listener is attached!
console.log('clicked');
});
}
// FIX: Don't close over what you don't need
function attachHandlerFixed() {
const largeData = new Array(1_000_000).fill('x');
const result = processData(largeData); // Extract only what you need
document.getElementById('btn').addEventListener('click', function() {
console.log('clicked, result:', result); // Only `result` is captured
});
// largeData is now eligible for GC after attachHandlerFixed() returns
}
// IMPORTANT: Also remove listeners when components unmount
function setupWithCleanup(element) {
const handler = () => console.log('clicked');
element.addEventListener('click', handler);
return () => element.removeEventListener('click', handler); // cleanup fn
}
The Comparison: With vs Without Closure
| Aspect | With Closure | Without Closure |
|---|---|---|
| Variable lifetime | Kept alive as long as the function reference exists | Freed when enclosing function returns |
| State isolation | Each closure instance has independent state | Shared via parameters or global state |
| Encapsulation | Private variables possible | Requires classes or other patterns |
| Memory footprint | Higher — referenced vars not GC'd | Lower — vars freed after return |
| Testing | Can be harder to inspect private state | State passed explicitly, easier to test |
Closures in React Hooks
React's hook system is built entirely on closures. Understanding this explains why you see stale closure warnings in linters, and how to avoid bugs in useEffect and useCallback.
import { useState, useEffect, useCallback } from 'react';
// STALE CLOSURE BUG
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// BUG: This closure captures `count` from the first render.
// count never changes inside this effect — it's always 0!
setCount(count + 1); // Always sets to 0 + 1 = 1
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps — closure is created once and never refreshed
return <div>{count}</div>;
}
// FIX 1: Functional update form (no closure over `count`)
function CounterFixed() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // ✓ Uses current value, not closure
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
// FIX 2: Add count to deps array (effect re-registers each time count changes)
function CounterFixed2() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ✓ count is always fresh
}, 1000);
return () => clearInterval(id);
}, [count]); // Re-run effect when count changes
return <div>{count}</div>;
}
// useCallback also creates closures — include all used variables in deps
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
const handleSearch = useCallback(() => {
onSearch(query); // Must include `query` in deps
}, [query, onSearch]); // ✓ Correct deps
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
The eslint-plugin-react-hooks rule exhaustive-deps automatically detects stale closure bugs in your React components. Always keep it enabled — it catches the same class of bug described above before it ships.
Closures with Arrow Functions
Arrow functions and closures interact in two important ways. First, arrow functions create closures just like regular functions. Second, arrow functions don't have their own this — they inherit it from the enclosing lexical scope, which is itself a form of closure behavior.
// Arrow functions close over `this` from the enclosing scope
class Timer {
constructor() {
this.seconds = 0;
}
start() {
// Regular function would lose `this` here
// Arrow function inherits `this` from start()'s scope
setInterval(() => {
this.seconds++; // ✓ `this` is the Timer instance
console.log(this.seconds);
}, 1000);
}
}
// Compare with regular function — would need .bind(this)
class TimerOld {
constructor() {
this.seconds = 0;
}
start() {
setInterval(function() {
// `this` would be `undefined` (strict mode) or `window`
// without .bind(this)
this.seconds++; // TypeError or wrong `this`!
}.bind(this), 1000); // Requires explicit binding
}
}
// Arrow function factories — clean and idiomatic
const makeAdder = a => b => a + b; // Curried with arrow functions
const add5 = makeAdder(5);
console.log(add5(3)); // 8
console.log(add5(10)); // 15
Building a Practical Event Emitter
Let's put everything together by building a real-world utility — a typed event emitter using closures for private state. This demonstrates how closures, factories, and the module pattern combine to produce clean, encapsulated APIs.
function createEventEmitter() {
// Private: Map of event name → Set of listeners
const listeners = new Map();
function on(event, listener) {
if (!listeners.has(event)) {
listeners.set(event, new Set());
}
listeners.get(event).add(listener);
// Return an unsubscribe function (closure over `event` and `listener`)
return function off() {
listeners.get(event)?.delete(listener);
};
}
function once(event, listener) {
const wrapper = (...args) => {
listener(...args);
off(); // Remove after first invocation
};
const off = on(event, wrapper);
return off;
}
function emit(event, ...args) {
listeners.get(event)?.forEach(listener => {
try {
listener(...args);
} catch (err) {
console.error(`Error in ${event} listener:`, err);
}
});
}
function listenerCount(event) {
return listeners.get(event)?.size ?? 0;
}
// Public API — private `listeners` Map stays hidden
return { on, once, emit, listenerCount };
}
// Usage
const bus = createEventEmitter();
const offLogin = bus.on('login', user => console.log(`${user} logged in`));
bus.once('ready', () => console.log('App is ready (fires once)'));
bus.emit('login', 'Alice'); // "Alice logged in"
bus.emit('ready'); // "App is ready (fires once)"
bus.emit('ready'); // (nothing — listener was removed)
offLogin(); // Unsubscribe
bus.emit('login', 'Bob'); // (nothing — listener removed)
Closure Patterns Quick Reference
| Pattern | What It Closes Over | Primary Use |
|---|---|---|
| Function Factory | Constructor arguments | Configurable, specialized functions |
| Module Pattern (IIFE) | Private state variables | Encapsulation, information hiding |
| Memoization | Cache (Map/Object) | Performance optimization |
| Partial Application | Pre-set arguments | Reusable specialized functions |
| Currying | Previously supplied args | Functional composition pipelines |
| Event Listener | DOM element or state | Callbacks with context |
| Unsubscribe / Cleanup | Subscription reference | Resource management |
Key Takeaways
Closures are not a trick or an edge case — they're a fundamental part of how JavaScript works. Here's what to take away from this deep dive:
Closures are about variable environments, not values
A closure captures a reference to the surrounding variable environment, not a snapshot of values. This is why the loop bug happens — and why functional updates solve the stale closure problem in React.
Every function in JavaScript is a closure
Any function defined inside another scope (including the global scope) closes over its outer variables. The practical difference is whether the outer function has returned by the time the inner function is called.
Use closures intentionally, be mindful of memory
Don't close over large data structures unless you actually need them. Particularly in event listeners, always clean up when the listener is no longer needed to prevent the closed-over variables from being retained indefinitely.
let/const in loops solves the classic closure bug
This is the most important practical rule. Always use let or const in loops when creating closures. If you're maintaining older code that uses var, the IIFE pattern is the classic fix.
What to Explore Next
- JavaScript Prototype Chain: Another fundamental mechanism that works alongside closures
- Generators and Iterators: Stateful functions that use closures internally
- WeakRef and FinalizationRegistry: Modern APIs for managing memory in closure-heavy code
- React useRef: Escape hatch for stale closure problems when functional updates don't apply
- Functional Programming in JS: Closures are the foundation of composable, purely functional code
"Closures are a feature, not a bug. Once you understand them, you'll see them everywhere in JavaScript — and start using them on purpose."
— Kyle Simpson, "You Don't Know JS"
Closures transform JavaScript from a simple scripting language into a powerful tool for building modular, stateful, and composable software. Whether you're writing React hooks, building utility libraries, or working with async code, a solid understanding of closures will make you a more effective and confident JavaScript developer.