08 · Performance & Memory¶
🎥 Video walkthrough¶
Correct code isn't automatically fast or memory-safe code. This module covers profiling to find real bottlenecks (instead of guessing), common memory-leak patterns in long-running Node processes, and general performance best practices.
Measuring before optimizing¶
The first rule of performance work: measure, don't guess. console.time
gives quick, rough timings for comparing approaches.
function sumWithLoop(n) {
let total = 0;
for (let i = 0; i < n; i += 1) {
total += i;
}
return total;
}
function sumWithReduce(n) {
return Array.from({ length: n }, (_, i) => i).reduce((a, b) => a + b, 0);
}
console.time("loop");
sumWithLoop(10_000_000);
console.timeEnd("loop"); // loop: ~5ms
console.time("reduce");
sumWithReduce(10_000_000);
console.timeEnd("reduce"); // reduce: ~250ms — building + reducing an array is much slower
Profiling with Node's built-in profiler¶
For a real bottleneck (not a 10-line example), Node can produce a CPU profile you can inspect for hot functions.
node --prof server.js
# ... exercise the app, then stop it ...
node --prof-process isolate-0x*-v8.log > profile.txt
[Summary]:
ticks total nonlib name
4820 62.4% 68.1% JavaScript
1830 23.7% 25.9% C++
950 12.3% GC
[Bottom up (heavy) profile]:
ticks parent name
2103 43.6% parseAndValidate (server.js:42)
...
GC (garbage collection) time appearing high in the summary is itself a
signal — it often means too many short-lived objects are being allocated,
which points back to a memory-usage problem rather than a raw CPU one.
The --inspect flag and Chrome DevTools¶
For interactive profiling (flame charts, heap snapshots), run Node with
--inspect and attach Chrome DevTools.
node --inspect server.js
# Debugger listening on ws://127.0.0.1:9229/...
# Open chrome://inspect in Chrome, click "inspect" under Remote Target
From DevTools you can record a CPU profile (flame chart of where time goes) or take a heap snapshot (a picture of every object currently allocated, useful for finding what's holding memory).
Common memory leak patterns in Node¶
A memory leak happens when references to objects are kept alive unintentionally, so the garbage collector can never reclaim them — memory usage grows over the life of the process until it crashes.
// LEAK: an ever-growing array with no eviction
const requestLog = [];
function handleRequest(req) {
requestLog.push(req); // never removed — grows forever as traffic comes in
}
// FIX: bound the size, or use a rolling window
const MAX_LOG_SIZE = 1000;
const requestLog = [];
function handleRequest(req) {
requestLog.push(req);
if (requestLog.length > MAX_LOG_SIZE) {
requestLog.shift(); // evict the oldest entry to keep memory bounded
}
}
// LEAK: forgetting to remove event listeners that are no longer needed
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
function attachPerRequestListener(bus) {
bus.on("data", (chunk) => console.log(chunk)); // a new listener added on every call!
}
// FIX: remove listeners when they're no longer needed, or use `.once`
function attachOneTimeListener(bus) {
bus.once("data", (chunk) => console.log(chunk)); // fires once, then auto-removes itself
}
function attachAndCleanUp(bus) {
function handler(chunk) {
console.log(chunk);
}
bus.on("data", handler);
return () => bus.off("data", handler); // caller invokes this to clean up
}
// LEAK: closures accidentally capturing large objects
function makeHandler(hugeBuffer) {
return function handler(req, res) {
res.send("ok"); // hugeBuffer is never used here...
};
// ...but it's still captured in the closure and kept alive for as long
// as `handler` exists, because closures keep their entire enclosing scope.
}
// FIX: only capture what you need
function makeHandler(hugeBuffer) {
const summary = hugeBuffer.length; // extract just the needed value
return function handler(req, res) {
res.send(`ok (${summary} bytes)`);
};
// hugeBuffer itself can now be garbage collected once makeHandler returns
}
Detecting a leak: watch memory over time¶
setInterval(() => {
const usage = process.memoryUsage();
console.log(`heapUsed: ${(usage.heapUsed / 1024 / 1024).toFixed(1)} MB`);
}, 5000);
// heapUsed: 42.3 MB
// heapUsed: 44.1 MB
// heapUsed: 61.7 MB <- climbing steadily under steady traffic is a red flag
// heapUsed: 89.4 MB
A healthy process's heap usage should plateau (rise, then fall back after garbage collection) under steady load. Continual, unbounded growth is the classic signature of a leak.
Performance best practices¶
| Practice | Why |
|---|---|
| Avoid unnecessary object/array allocation in hot loops | fewer allocations = less GC pressure |
| Use streams for large files/responses | keeps memory flat regardless of data size |
| Paginate database queries | avoids loading huge result sets into memory at once |
| Cache expensive, repeatable computations | trades memory for CPU when appropriate |
| Debounce/throttle high-frequency events | limits how often expensive work runs |
Prefer Map/Set over objects/arrays for large lookup-heavy data |
O(1) lookups, no prototype chain overhead |
| Profile before optimizing | fixes the actual bottleneck instead of a guessed one |
Debouncing an expensive operation¶
function debounce(fn, delayMs) {
let timeoutId;
return function debounced(...args) {
clearTimeout(timeoutId); // cancel any pending call
timeoutId = setTimeout(() => fn(...args), delayMs);
};
}
function expensiveSearch(query) {
console.log(`searching for "${query}"...`);
}
const debouncedSearch = debounce(expensiveSearch, 300);
debouncedSearch("a");
debouncedSearch("ap");
debouncedSearch("app"); // only this call actually runs expensiveSearch, ~300ms later
How It Actually Works¶
V8's garbage collector is generational: it splits the heap into a small "young generation" (further split into a tiny nursery and an intermediate space) and a much larger "old generation." Nearly all objects die young — short-lived intermediate values, one-off closures, temporary arrays — so V8 optimizes for that case with Scavenge, a fast copying collector that runs frequently on just the young generation and copies surviving objects out to a survivor space; objects that survive two scavenges get promoted to the old generation. The old generation is collected far less often, using a slower mark-sweep-compact algorithm (Mark-Compact), because scanning and compacting a large heap is expensive and old objects are statistically much less likely to have become garbage.
Memory leaks in long-running Node processes almost always trace back to something
holding a reference the developer thinks is temporary: an ever-growing array pushed to
but never trimmed, event listeners attached repeatedly without ever being removed (each
one keeps its closure's entire captured environment alive), or a Map/Set used as a
cache with no eviction policy. WeakMap/WeakSet exist specifically to break this
class of leak — their keys don't count as strong references for garbage collection
purposes, so an entry disappears on its own once nothing else references the key,
without needing manual cleanup code at all.
Exercise¶
Write a LRUCache class (least-recently-used cache) backed by a Map with
a fixed maxSize: get(key) returns the value and marks it recently used;
set(key, value) adds/updates an entry and evicts the least-recently-used
entry when the cache exceeds maxSize. Then write a small script that adds
more entries than maxSize allows and logs the final contents, confirming
the oldest unused entries were evicted rather than the cache growing
unbounded.