How JavaScript Closures Work
You’ve written a function inside another function, and somehow the inner function still accesses variables from the outer one—even after the outer function finished executing. This behavior confuses many developers, but it follows a simple rule: closures capture bindings, not values.
This article explains how JavaScript closures work, what lexical scope actually means, and how to avoid common mistakes that stem from misunderstanding these mechanics.
Key Takeaways
- A closure is a function combined with its lexical environment—the variable bindings that existed when the function was created.
- Closures capture bindings (references), not values, so mutations to closed-over variables remain visible.
- Use
letorconstin loops to create fresh bindings per iteration and avoid the classic loop problem. - Memory issues stem from retaining unnecessary references, not from closures themselves.
What is a closure?
A closure is a function combined with its lexical environment—the set of variable bindings that existed when the function was created. Every function in JavaScript forms a closure at creation time.
function createGreeter(greeting) {
return function(name) {
return `${greeting}, ${name}`
}
}
const sayHello = createGreeter('Hello')
sayHello('Alice') // "Hello, Alice"
When createGreeter returns, its execution context ends. Yet the returned function still accesses greeting. The inner function didn’t copy the string “Hello”—it retained a reference to the binding itself.
Lexical scope in JavaScript explained
Lexical scope means variable access is determined by where functions are defined in source code, not where they execute. The JavaScript engine resolves variable names by walking up the scope chain from the innermost scope outward.
const multiplier = 2
function outer() {
const multiplier = 10
function inner(value) {
return value * multiplier
}
return inner
}
const calculate = outer()
calculate(5) // 50, not 10
The inner function uses multiplier from its lexical environment—the scope where it was defined—regardless of any global variable with the same name.
Closures capture bindings, not snapshots
A common misconception is that closures “freeze” variable values. They don’t. Closures hold references to bindings, so mutations remain visible:
function createCounter() {
let count = 0
return {
increment() { count++ },
getValue() { return count }
}
}
const counter = createCounter()
counter.increment()
counter.increment()
counter.getValue() // 2
Both methods share the same count binding. Changes made by increment are visible to getValue because they reference the same variable.
The classic loop problem: var versus let
This distinction matters most in loops. With var, all iterations share one binding:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// Logs: 3, 3, 3
Each callback closes over the same i, which equals 3 when the callbacks execute.
With let, each iteration creates a fresh binding:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// Logs: 0, 1, 2
Block scoping gives each closure its own i.
Discover how at OpenReplay.com.
Practical patterns: factory functions and event handlers
Closures enable factory functions that produce specialized behavior:
function createValidator(minLength) {
return function(input) {
return input.length >= minLength
}
}
const validatePassword = createValidator(8)
validatePassword('secret') // false
validatePassword('longenough') // true
Event handlers naturally use closures to retain context:
function setupButton(buttonId, message) {
document.getElementById(buttonId).addEventListener('click', () => {
console.log(message)
})
}
The callback retains access to message long after setupButton returns.
Memory considerations: what closures actually retain
Closures don’t inherently cause memory leaks. Problems arise when functions unintentionally retain references to large objects or long-lived data structures.
function processData(largeDataset) {
const summary = computeSummary(largeDataset)
return function() {
return summary // Only retains summary, not largeDataset
}
}
If your closure only needs a small piece of data, extract it before creating the inner function. The original large object becomes eligible for garbage collection.
Modern JavaScript engines optimize closures aggressively. They’re a normal language feature, not a performance concern in typical usage. The issue isn’t closures themselves—it’s retaining references you don’t need.
Building a reliable mental model
Think of closures this way: when a function is created, it captures a reference to its surrounding scope. That scope contains bindings—connections between names and values—not the values themselves. The function can read and modify those bindings throughout its lifetime.
This model explains why mutations are visible, why let fixes loop problems, and why closures work across asynchronous boundaries. The function and its lexical environment travel together.
Conclusion
Closures are functions bundled with their lexical environment. They capture bindings, not values, so changes to closed-over variables remain visible. Use let or const in loops to create fresh bindings per iteration. Memory issues stem from retaining unnecessary references, not from closures themselves.
Understanding JavaScript scope and closures gives you a foundation for reasoning about variable lifetime, data encapsulation, and callback behavior—patterns you’ll encounter daily in frontend development.
FAQs
Every function in JavaScript is technically a closure because it captures its lexical environment at creation time. The term closure typically refers to functions that access variables from an outer scope after that scope has finished executing. A function that only uses its own local variables or global variables doesn't demonstrate closure behavior in a meaningful way.
Closures don't inherently cause memory leaks. Problems occur when closures unintentionally retain references to large objects or data structures that should be garbage collected. To avoid this, extract only the data your closure needs before creating the inner function. Modern JavaScript engines optimize closures aggressively, so they're not a performance concern in typical usage.
This happens when using var in loops because var is function-scoped, not block-scoped. All iterations share the same binding, so callbacks see the final value when they execute. Fix this by using let instead, which creates a fresh binding for each iteration. Each closure then captures its own copy of the loop variable.
Yes. Closures capture bindings (references to variables), not snapshots of values. If the closed-over variable changes, the closure sees the updated value. This is why multiple functions sharing the same closure can communicate through shared variables, as seen in the counter pattern where increment and getValue methods share the same count binding.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.