JavaScript closures explained with everyday code
Learn JavaScript closures with tested examples: counters, the var vs let loop question, private state, debounce, and the stale closure bug in React hooks.

A closure is a function that remembers the variables around it, even after the code that created those variables has finished running. JavaScript creates one every time it creates a function, which means you have been using closures since your first event handler or array callback.
The definition is not the hard part. The hard part is predicting what a closure remembers. This guide builds that prediction skill with small programs you can run, then applies it to the places closures actually show up: counters, timers, private state, debounce, and the React hook bug that catches almost everyone once.
Start with scope
JavaScript decides which variables a function can see by looking at where the function is written, not where it is called. This is called lexical scope. A function can read its own variables, its parameters, and any variable declared in the functions and blocks that surround it.
const greeting = "Hello"
function greet(name) {
const punctuation = "!"
function build() {
return greeting + ", " + name + punctuation
}
return build()
}
console.log(greet("Ada")) // Hello, Ada!build never declares greeting, name, or punctuation, but it can use all three because it sits inside greet, and greet sits inside the file. The lookup goes outward, one level at a time, until the variable is found.
If nested functions and scope are new to you, the Scope (opens in a new tab) lesson in JavaScript Basics covers the rules before you continue.
A function remembers where it was created
Now the interesting part. What happens when an inner function outlives the function that created it?
function makeCounter() {
let count = 0
return function () {
count += 1
return count
}
}
const counter = makeCounter()
console.log(counter()) // 1
console.log(counter()) // 2
console.log(counter()) // 3makeCounter runs once and returns. By the usual rules, its local variable count should be gone. Instead, every call to counter increments the same count. The returned function kept a reference to the environment it was created in, and that reference keeps count alive.
That combination, a function plus the variables it can reach, is the closure. MDN describes it as a function bundled together with references to its surrounding state.
Each call to makeCounter creates a fresh environment, so two counters do not share state:
const other = makeCounter()
console.log(other()) // 1
console.log(counter()) // 4Closures capture variables, not values
This is the rule that explains almost every closure question. A closure holds a live reference to a variable, not a snapshot of the value it had when the function was created.
let message = "first"
function show() {
console.log(message)
}
message = "second"
show() // secondshow was created while message was "first", but it prints "second" because it reads the variable at call time.
The classic interview question is the same rule wearing a loop:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 3
// 3
// 3Every arrow function closes over the same i. var is function scoped, so the whole loop shares one variable, and the loop finishes with i at 3 before any timer runs. When the callbacks finally execute, they all read the same variable and see the same final value.
Change var to let and the output changes:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 0
// 1
// 2A let declared in a for header gets a fresh binding on every iteration. The ECMAScript specification calls this step CreatePerIterationEnvironment, and MDN's for statement reference documents the behavior. Each callback closes over its own copy of i, so each one remembers a different number.
If an interviewer asks how to fix the var version without let, the answer is to create a new scope per iteration yourself, for example by passing i into a function as a parameter.
You already write closures every day
Closures are not an advanced feature you opt into. They are how ordinary callbacks get their context.
Array callbacks read variables from the surrounding function:
const scores = [72, 95, 58, 88]
const passingScore = 70
const passing = scores.filter((score) => score >= passingScore)
console.log(passing) // [ 72, 95, 88 ]The arrow function receives score as a parameter, but passingScore is not passed in. It is captured. The Filter (opens in a new tab) lesson in Intermediate JavaScript uses this pattern throughout.
Promise callbacks do the same thing, which is how a value that was known before the wait is still available after it:
function fetchUser(id) {
return new Promise((resolve) => {
setTimeout(() => resolve({ id, name: "Ada" }), 100)
})
}
function loadUser(id) {
return fetchUser(id).then((user) => {
console.log(`Loaded user ${id}: ${user.name}`)
})
}
loadUser(7) // Loaded user 7: AdaThe .then callback runs later, when the response arrives, and it still knows which id was requested. async and await remove the visible callback: the engine pauses the function and resumes it later with its own variables intact, which is suspension rather than a closure. Any callback you create inside an async function still captures its surroundings exactly the way the .then callback does. The Promises (opens in a new tab) lesson builds this up from scratch.
Event handlers, setTimeout callbacks, and custom hooks all work the same way. Once you see the pattern, you will notice it everywhere.
If any of those callbacks felt unfamiliar, these courses cover the ground: array methods and async code in Intermediate JavaScript, the structure to put them in with Software Design Principles, and the fundamentals in JavaScript Basics if you want to go back a step:
Check out our related courses
Private state with closures
Because a closure keeps variables alive without exposing them, it is the original way to make state private in JavaScript.
function createAccount(openingBalance) {
let balance = openingBalance
return {
deposit(amount) {
balance += amount
return balance
},
getBalance() {
return balance
},
}
}
const account = createAccount(100)
account.deposit(50)
console.log(account.getBalance()) // 150
console.log(account.balance) // undefined
account.balance = 1_000_000
console.log(account.getBalance()) // 150balance is not a property of the returned object. It lives in the environment of the createAccount call, and the only code that can reach it is the two methods created there. Setting account.balance from outside just adds an unrelated property.
Modern classes offer private fields with a # prefix for the same goal. Closures remain the tool for private state in functions, modules, and hooks. A module that keeps an array at file scope and exports only the functions that use it is doing the same thing at a larger scale, which is the design the Encapsulation And Information Hiding (opens in a new tab) lesson builds.
Functions that build functions
A function that returns a function is a closure factory. The returned function remembers the arguments of the call that created it.
function multiplier(factor) {
return (value) => value * factor
}
const double = multiplier(2)
const triple = multiplier(3)
console.log(double(7)) // 14
console.log(triple(7)) // 21Two utilities you will meet in real codebases are built the same way. once guards a function so it runs a single time:
function once(fn) {
let called = false
let result
return function (...args) {
if (!called) {
called = true
result = fn.apply(this, args)
}
return result
}
}
const connect = once(() => {
console.log("Connecting...")
return "connection-1"
})
console.log(connect())
// Connecting...
// connection-1
console.log(connect())
// connection-1debounce waits until calls stop arriving before running the function, which is how search boxes avoid sending one request per keystroke:
function debounce(fn, delay) {
let timer
return function (...args) {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
const search = debounce((query) => {
console.log(`Searching for "${query}"`)
}, 300)
search("j")
search("ja")
search("jav")
search("java")
// 300 ms later:
// Searching for "java"The timer variable is shared across every call to search because there is only one closure. Each new call clears the previous timeout and schedules a new one, and only the last one survives. Both wrappers are regular functions rather than arrows so that this still reaches fn when the wrapped function is used as a method or an event listener. Both helpers are common interview follow-ups because they prove you understand that the captured variable persists between calls.
The stale closure bug in React
React components are functions, so every render creates new closures. Most of the time that is exactly what you want. It becomes a bug when a closure from an old render keeps running.
import { useEffect, useState } from "react"
function Timer() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [])
return <p>{count}</p>
}The count climbs to 1 and stops. The effect runs once, after the first render, and the interval callback it creates closes over that render's count, which is 0. Every tick computes 0 + 1 and sets the state to 1 again. Later renders have their own count, but the interval is still calling the old function. We confirmed this against React 19.2 with a 10 millisecond interval: after 150 milliseconds the stale version still showed 1, while the fixed version below had counted past 10.
Plain JavaScript shows the mechanism without React:
function render(count) {
return () => console.log("count is", count)
}
const firstRender = render(0)
const secondRender = render(1)
firstRender() // count is 0
secondRender() // count is 1
firstRender() // count is 0The fix is to stop depending on the captured value. Pass an updater function so React hands you the current state:
setCount((current) => current + 1)React's documentation on updating state based on the previous state explains why the updater form is the correct tool here, and the Updating From Previous State (opens in a new tab) lesson has you practice it. When the effect really does need to read the latest value, list it in the dependency array so the effect re-runs with a fresh closure, or keep it in a ref, which is the technique in Refs For Persistent Values (opens in a new tab).
Closures and memory
A closure keeps its captured variables alive for as long as the closure itself is reachable. That is the feature, and it is also the only way closures cause memory problems.
function attachLogger(button) {
const history = new Array(1_000_000).fill(0)
button.addEventListener("click", () => {
console.log("history size:", history.length)
})
}As long as the button and its listener stay reachable, history stays alive with them, because the listener still references it. A closure is not a leak, but a long-lived closure over a large object is a decision to keep that object around for as long as the closure itself lives. Remove listeners and clear intervals when you are done with them, and do not capture more than the callback needs.
How to answer the interview question
Interviewers rarely want the definition alone. They want to see whether you can predict behavior. A complete answer has four parts:
- The definition: a function bundled with the variables it can reach from where it was defined.
- A working example, usually the counter, showing state that survives after the outer function returns.
- The rule that closures capture variables rather than values, demonstrated with the
varversusletloop. - A real use: private state,
debounce,once, or a React updater function.
The interview tests whether you understand what your code does. Closures are the fastest way for an interviewer to find out.
A practical rule for reading closures
When a function uses a variable it did not declare and did not receive as a parameter, it is using a closure. Ask two questions about that variable: is it shared with other functions, and can it change before this function runs? The answers predict the behavior every time.









