Updated June 2026. Tested on modern JavaScript (ES2020+). Part of the Techalyst JavaScript series.
Closures sound academic until you realise you use them constantly: every callback that remembers a variable, every module with private state, every debounce. A closure is just a function that keeps access to the scope it was born in. Pair that with the factory pattern and you have a clean way to build independent, encapsulated objects. Let us build the idea up from scope.
Scope flows one way
A function can read variables from the scope around it, but the outside cannot reach in. That is the whole basis of closures.
let global = 'everyone sees me'
function outer() {
let secret = 'only inner sees me'
function inner() {
return secret // inner reaches up into outer's scope
}
return inner
}
console.log(secret) // ReferenceError: secret is not defined
secret is invisible from outside outer, but inner (defined inside) can read it. The question is what happens to secret after outer finishes.
The closure: state that outlives the call
Normally a function's local variables vanish when it returns. But if you return the inner function, it keeps a live link to outer's scope, so secret stays alive.
const read = outer() // outer runs once, returns inner
console.log(read()) // 'only inner sees me', secret is still here
read is a closure. It closes over secret, which is now reachable only through read, not from the outside world. That is private state in plain JavaScript, no class needed.
function createCounter() {
let count = 0 // private
return {
increment: () => ++count,
value: () => count,
}
}
const counter = createCounter()
counter.increment()
counter.increment()
console.log(counter.value()) // 2
console.log(counter.count) // undefined, no outside access
Nobody can set count to a nonsense value, because there is no handle to it except the two functions you exposed. The single shared count, persisting across calls, is the heart of the pattern.
Why a debounce needs a closure
The classic debounce is a closure in disguise. It needs one timer id shared across every call, and the only place to keep it is the outer scope.
function debounce(fn, delay) {
let timer = null // shared across every call of the returned function
return function (...args) {
clearTimeout(timer) // cancel the pending run
timer = setTimeout(() => fn(...args), delay)
}
}
const onSearch = debounce((q) => fetchResults(q), 400)
Every keystroke calls the returned function, which clears the previous timer and sets a new one. Because timer lives in the outer scope, all those calls share it, so only the final keystroke survives. Move timer inside the returned function and it resets to null every call, breaking the whole thing. (There is a Vue-flavoured version of this built into a ref.)
The factory pattern: independent instances
A factory is a function that returns a brand-new object every time it is called. Combined with closures, each returned object gets its own private scope, fully isolated from the others.
function createUser(name) {
let loginCount = 0 // private to this instance
return {
name,
login() {
loginCount++
return `${name} has logged in ${loginCount} time(s)`
},
}
}
const a = createUser('Aisha')
const b = createUser('Ravi')
a.login()
a.login()
b.login()
// a's loginCount is 2, b's is 1, completely separate
This is the opposite of a singleton (a single shared object). The factory gives data independence: mutating one instance never touches another, because each call created its own closure scope. Reach for it whenever you need several of something that must not interfere, several counters, several form controllers, several cancellable requests.
A practical factory: cancellable requests
Here is a real use. You want each fetch to be independently cancellable, so a factory hands back a request paired with its own controller. Modern JavaScript uses AbortController for this.
function createSearch() {
const controller = new AbortController() // one per factory call
async function run(term) {
try {
const res = await fetch(`/api/search?q=${term}`, {
signal: controller.signal,
})
return await res.json()
} catch (err) {
if (err.name === 'AbortError') return // cancelled, not an error
throw err
}
}
return { run, cancel: () => controller.abort() }
}
const first = createSearch()
const second = createSearch()
first.run('laravel')
second.run('vue')
first.cancel() // aborts only the first request; the second runs on
Because each call to createSearch closes over its own controller, cancelling one leaves the other untouched. A single shared controller would cancel both, which is exactly the bug the factory pattern avoids.
Wrapping up
A closure is a function that retains access to the scope it was defined in, which gives you persistent, private state without classes. The factory pattern uses that to mint independent instances, each with its own sealed-off variables. Together they power debounces, counters, modules, and cancellable requests. Once you see that "function plus remembered scope" everywhere, a lot of JavaScript stops being mysterious.
More in the series: arrow functions and this and scope, hoisting and references. Questions welcome below.
All comments ()
No comments yet
Be the first to leave a comment on this post.