Free lesson · Node.js Deep Dive
Closures, Really: In the Context of Node Callbacks
Closures, Really: In the Context of Node Callbacks
The first time I "understood" closures was sometime around week six of freeCodeCamp. I read the definition - a function that captures variables from its enclosing scope - nodded, did the exercise, moved on.
The second time I understood closures was about three weeks later, when I wrote a for loop that fired off five setTimeout callbacks, each of which was supposed to print a different number, and instead all five printed 5. I spent ninety minutes on Stack Overflow before I realized I'd just demonstrated, the hard way, that I hadn't understood closures the first time.
The third, fourth, and fifth times I understood closures came progressively, over the next year, every time I wrote code where the answer was "use a closure" and I had to think for several minutes before I could explain why. I don't think I really had closures internalized until I started writing serious Node code. Node is essentially a closure machine - every callback you pass to fs.readFile, every event handler you register on an EventEmitter, every .then callback on a Promise is a closure capturing something. You have to know closures cold or you'll write Node code that does mysterious things.
This lesson is going to teach closures the way I wish someone had taught them to me - not as a definition, but as a mechanism. We'll start with the mechanism, build up to the patterns Node depends on, hit the specific traps that bite working developers, and finish with the production patterns where closures earn their keep.
What a Closure Actually Is
Let me start with a question that the standard definition doesn't answer well: when you define a function inside another function, where do the inner function's "outer variables" live?
function outer() {
const message = "I live in outer's scope";
function inner() {
console.log(message);
}
return inner;
}
const captured = outer();
// outer has returned. Its execution is finished.
// What happens when we call captured()?
captured();
Run this and you'll see "I live in outer's scope" printed.
But wait. outer() has returned. Its local variables - including message - should be gone. Local variables in most languages live on the call stack, and when a function returns, its stack frame is destroyed. By all reasonable expectations, message should be unreachable. And yet, when we call captured(), JavaScript happily finds it.
This is the mechanism. A closure is what happens when a function holds a reference to its outer scope's variables, keeping them alive even after the outer function has returned. It's not a magical capture-on-define operation; it's that JavaScript variables live in the heap, not the stack, when they're closed over by an inner function, and the inner function carries a hidden reference to them.
flowchart TD
Outer["outer() execution"]
Inner["inner function"]
Scope["Closure scope<br/>(heap-allocated)<br/>message: 'I live...'"]
Captured["captured = inner"]
Outer -->|defines| Inner
Inner -.->|hidden reference| Scope
Outer -->|returns inner| Captured
Stack["After outer returns:<br/>outer's stack frame is gone"]
Captured -.->|still holds inner| Inner
Inner -.->|still references| Scope
style Scope fill:#34d399,stroke:#10b981,color:#000
style Stack fill:#fbbf24,stroke:#f59e0b,color:#000
A flowchart showing closure mechanics. The outer function executes, defining an inner function. The inner function holds a hidden reference to a closure scope on the heap containing the outer's variables. When outer returns the inner function, the caller stores it in a variable called captured. After outer returns, its stack frame is gone - but the closure scope on the heap survives because the inner function still references it, and the variable captured still references the inner function. The chain keeps the closed-over variables alive.
Two things are worth being precise about, because they're often glossed over:
Closures capture variables, not values. This is the single most important thing to internalize. When inner closes over message, it doesn't take a snapshot of the string "I live in outer's scope". It captures a reference to the variable message itself. If outer modified message after defining inner, the closure would see the new value:
function outer() {
let message = "first value";
function inner() {
console.log(message);
}
message = "second value"; // mutate AFTER defining inner
return inner;
}
const captured = outer();
captured(); // prints "second value"
If closures captured values instead of variables, this would print "first value". But it prints "second value", because message is the thing being referenced, and message at the moment of inner() invocation is "second value".
Each invocation of outer creates a new scope. Calling outer twice gives you two completely separate closure scopes:
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counterA = makeCounter();
const counterB = makeCounter();
console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterA()); // 3
console.log(counterB()); // 1 - fresh scope, fresh count
console.log(counterA()); // 4 - counterA's count is still independent
counterA and counterB each have their own private count variable. The closure scope is per-invocation-of-the-outer-function.
This is the foundation. Everything else about closures is consequence.
Why Closures Show Up Everywhere in Node
Node's core programming model is callbacks. You ask Node to do something - read a file, listen for a request, run after a timeout - and you give it a function to call when the thing is done. That function almost always needs access to context from where it was defined.
const fs = require('node:fs');
function loadUserConfig(userId, onComplete) {
const path = `/configs/${userId}.json`;
fs.readFile(path, 'utf8', (err, data) => {
if (err) {
onComplete(err);
return;
}
onComplete(null, { userId, path, data });
});
}
The callback passed to fs.readFile references userId, path, and onComplete. All three are from loadUserConfig's scope. By the time fs.readFile calls back - milliseconds later, when the OS has finished reading the file - loadUserConfig may have returned. But the callback still has access to those variables. That's a closure.
This pattern is everywhere in Node. Every time you pass a function to:
fs.readFile,fs.writeFile, anything infswith a callback variant- An HTTP route handler:
app.get('/users', (req, res) => { ... }) - An EventEmitter listener:
emitter.on('data', chunk => { ... }) - A timer:
setTimeout(() => { ... }, 1000) - A
.thenon a Promise:promise.then(value => { ... })
…you are creating a closure. The callback captures whatever variables it needs from its surrounding scope, and those variables stay alive until the callback either fires or becomes unreachable.
This is why you have to actually understand closures to write Node code. Other ecosystems use callbacks too, but Node's is primarily callback-based at the lowest level. The closure is the basic unit of state-carrying-across-async-boundaries in Node.
The Loop-and-Closure Trap
This is the classic. Every JavaScript developer alive has hit it at least once. Let's reproduce it:
// BUGGY: prints 5, 5, 5, 5, 5
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
Read the code. Your first instinct is probably "this should print 0, 1, 2, 3, 4." But it prints 5, 5, 5, 5, 5.
Why? Because of the rule we just covered: closures capture variables, not values. The var i declaration creates one variable named i, shared across the entire loop. All five callbacks close over the same i. When setTimeout fires those callbacks 100ms later, the loop has long since finished, and i is now 5. All five callbacks read 5 from the one shared variable.
flowchart LR
Var["var i<br/>(single variable)"]
CB1["Callback 1<br/>(scheduled at i=0)"]
CB2["Callback 2<br/>(scheduled at i=1)"]
CB3["Callback 3<br/>(scheduled at i=2)"]
CB4["Callback 4<br/>(scheduled at i=3)"]
CB5["Callback 5<br/>(scheduled at i=4)"]
CB1 -.references.-> Var
CB2 -.references.-> Var
CB3 -.references.-> Var
CB4 -.references.-> Var
CB5 -.references.-> Var
Final["When callbacks fire,<br/>i = 5 (loop has completed)"]
Var --> Final
style Var fill:#f87171,stroke:#ef4444,color:#000
A flowchart showing the loop-and-closure trap. A single var i declaration creates one variable shared across all loop iterations. Each of the five setTimeout callbacks closes over a reference to the same i. When the callbacks finally fire after 100 milliseconds, the loop has already completed and i is now 5. All five callbacks read the same final value.
The fix is straightforward once you understand the mechanism: use let instead of var.
// CORRECT: prints 0, 1, 2, 3, 4
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
let is block-scoped. The for loop creates a new binding of i for each iteration. Five iterations, five separate i variables, five separate closures. Each callback closes over its own iteration's i.
This was a real change in ES6 (2015). Before let existed, the only fix was to introduce an immediately-invoked function expression (IIFE) to create a new scope per iteration:
// The pre-2015 workaround
for (var i = 0; i < 5; i++) {
(function(capturedI) {
setTimeout(() => {
console.log(capturedI);
}, 100);
})(i);
}
You don't need to write this anymore. Use let. But you'll see this pattern in old codebases, and now you know why it's there.
Important: The lesson here isn't "use
let." That's a rule. The lesson is why the rule exists - closures capture variables, andvarproduces fewer variables thanletdoes. Internalize the why and you'll never be confused by similar bugs in different shapes.
A Trap You Will See in Node Specifically: Async Loops
The loop-and-closure trap has a Node-flavored variant that bites people who do know about let but think they've outgrown the issue. Here it is:
// What's wrong with this?
const files = ['a.txt', 'b.txt', 'c.txt'];
for (const file of files) {
fs.readFile(file, 'utf8', (err, data) => {
if (err) return console.error(`Failed: ${file}`, err);
console.log(`Read ${file}: ${data.length} bytes`);
});
}
This one actually works correctly! for...of with const creates a new file binding per iteration, so the closure captures the right value each time. But this is a fragile area - the moment someone refactors to use indices, or uses var, or tries something clever, the bug returns.
The pattern I push on my team is: when you have a loop spawning async work, use either Promise.all with .map, or an explicit for...of with await if you need sequential execution. We'll cover both in Lesson 2.4. Either is more robust than callback-based loops.
Closures and Memory: The Underdiscussed Side
Closures keep their captured variables alive. That means they can keep memory alive that you didn't expect to be kept alive.
function createLargeData() {
const giantArray = new Array(1_000_000).fill('some data');
const smallValue = 'hello';
return function getSmallValue() {
return smallValue;
};
}
const fn = createLargeData();
// At this point, giantArray is gone from the stack...
// But is it actually garbage-collected?
The intuitive answer is "yes, garbage-collected - we returned the small function, we don't need giantArray."
The pedantic answer is "it depends." Specifically, V8 (Node's engine) is smart enough to analyze which variables a closure actually references. In the example above, getSmallValue references smallValue but not giantArray, so V8 can garbage-collect giantArray. Modern V8 does this optimization. But it's not guaranteed by the spec, and other JavaScript engines or older versions of V8 may keep the entire closure scope alive.
The defensive posture: if you're capturing a function for long-lived use, be careful about what's in the surrounding scope. The bigger the scope's variables, the bigger the potential memory hold.
Where this bites in production:
// Memory leak pattern
class CacheService {
constructor() {
this.handlers = new Map();
}
registerHandler(id, makeHandler) {
const massiveContext = this.loadEverything(); // 500MB of data
const handler = makeHandler(massiveContext);
this.handlers.set(id, handler);
}
}
If makeHandler returns a closure that doesn't reference massiveContext, modern V8 will let massiveContext be collected. If makeHandler returns a closure that captures massiveContext, the 500MB stays in memory as long as the handler is referenced by this.handlers. Multiply by hundreds of registrations and you've got a real memory problem.
This is one of the bug patterns I genuinely have shipped to production at Microsoft. We have closure-based handlers in VS Code Remote Development that, in early versions, captured more context than they needed. The fix wasn't to abandon closures - it was to be precise about what each closure needed to capture, and pass only that.
We'll come back to memory leak debugging in Module 13. For now: know that closures hold their scope, and "their scope" can be larger than you'd expect.
Useful Closure Patterns You'll Use in Node
Now the productive part. Closures aren't just a trap to avoid - they're a primary tool. Here are the patterns you'll use constantly.
Pattern 1: Function factories
A function that returns a customized function.
function makeLogger(prefix) {
return function (message) {
console.log(`[${prefix}] ${new Date().toISOString()} ${message}`);
};
}
const authLog = makeLogger('auth');
const dbLog = makeLogger('db');
authLog('User signed in');
dbLog('Query took 200ms');
authLog('Token refreshed');
authLog and dbLog are separate functions, each closing over their own prefix. Function factories are how you make customized functions from a template. Express middleware functions are usually function factories: cors({ origin: '*' }) returns a middleware function with the origin closed over.
Pattern 2: Module pattern (private state)
function createCounter() {
let count = 0;
return {
increment() { count += 1; return count; },
decrement() { count -= 1; return count; },
value() { return count; },
reset() { count = 0; }
};
}
const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.increment(); // 3
console.log(counter.value()); // 3
counter.reset();
console.log(counter.value()); // 0
// You CANNOT do this:
// counter.count = 999; // would create a new property, not affect closure's count
// console.log(counter.count); // 999, but counter.value() still returns 0
count is genuinely private. It's only accessible through the methods the closure returns. This is the JavaScript pattern for encapsulation before classes had private fields (which arrived in ES2022). Many Node libraries you'll use are written in this style.
Pattern 3: Memoization
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const slowFunction = (n) => {
// Simulate work
let sum = 0;
for (let i = 0; i < n; i++) sum += i;
return sum;
};
const fastFunction = memoize(slowFunction);
console.time('first');
fastFunction(1_000_000);
console.timeEnd('first'); // Slow - actual computation
console.time('second');
fastFunction(1_000_000);
console.timeEnd('second'); // Fast - cached
The cache lives in the closure. It's private to this memoized version. You could make another memoized version of the same function with a separate cache.
Pattern 4: Partial application
function partial(fn, ...presetArgs) {
return function (...restArgs) {
return fn(...presetArgs, ...restArgs);
};
}
const addThree = (a, b, c) => a + b + c;
const addFiveAndSeven = partial(addThree, 5, 7);
console.log(addFiveAndSeven(10)); // 22
console.log(addFiveAndSeven(20)); // 32
partial returns a function that closes over the preset arguments. You can build whole functional programming libraries on this pattern (look up Ramda or lodash/fp).
Pattern 5: Event handlers with context
class FileWatcher {
constructor(directory) {
this.directory = directory;
this.handlers = [];
}
onFileChange(handler) {
// The handler we register closes over `this.directory`
fs.watch(this.directory, (eventType, filename) => {
const fullPath = path.join(this.directory, filename);
handler({ eventType, filename, fullPath, time: Date.now() });
});
}
}
The callback to fs.watch is a closure that captures this.directory and handler. Without closures, you'd have to pass context through some other mechanism (which Node's older APIs did via a context argument; modern APIs assume closures).
What I Want You to Take From This Lesson
Three things.
One: Closures capture variables, not values. This is the rule that explains the loop trap, the why-did-this-print-undefined trap, and most of the closure-related bugs you'll ever write. Internalize the mechanism and the rules follow.
Two: Node is closure-heavy by design. Every callback in Node is a closure. Promise .then handlers are closures. Event listeners are closures. Async/await variables passed through awaits are conceptually closure-captured. You will write hundreds of closures per project. Be precise about what each one captures.
Three: Closures aren't free. They hold memory. Be aware of what's in the surrounding scope when you create a closure that will be retained for a long time. The pattern of "register a callback that captures the entire surrounding state" can produce memory leaks that are hard to find later.
The next lesson is this, bind, call, apply - JavaScript's other source of weird-this-isn't-working confusion. We'll move faster through that one because it's more reference-flavored, but the patterns matter for Node.
Try It
Exercise: Warmup (10 minutes). Predict the output of this code before running it. Then run it. If your prediction is wrong, write down which rule of closures you forgot.
function makeAdders() { const adders = []; for (var i = 0; i < 3; i++) { adders.push(function(n) { return n + i; }); } return adders; } const [a, b, c] = makeAdders(); console.log(a(10), b(10), c(10));Then replace
var iwithlet iand predict the new output. Run it.
Exercise: Core (20 minutes). Implement a
rateLimiterfunction: it takes a functionfnand a numbermaxCallsPerSecond. It returns a new function that, when called more thanmaxCallsPerSecondtimes in any rolling 1-second window, throws. Use a closure to hold the call timestamps. Test with a function that prints the current time, called in a loop. Verify the rate limit works.
Exercise: Core (15 minutes). Build a counter that exposes only an
increment()andgetValue()method - no way to set count to a specific value, no way to read count except via the method. Now write a test that tries to corrupt the counter's internal state by reassigning, deleting, or replacing the methods. Document what you can and cannot do.
Exercise: Stretch (45 minutes). Read the V8 blog post on closures and memory (search for "closure" or "memory"). Then construct an example where a closure unnecessarily retains a large object that you didn't intend to keep alive. Take a heap snapshot in Chrome DevTools or via Node's
--inspectflag. Verify the leak. Then refactor to avoid the retention. Re-snapshot. Confirm the difference.
What's Next
Lesson 2.2 is a reference lesson on this, bind, call, and apply. These are concepts that confuse a lot of JS developers - including a younger version of me - and they show up enough in Node patterns (EventEmitter methods, class-based code) that they're worth getting right. It's lighter on narrative and heavier on tables and examples than this lesson; that's appropriate for the content.