Measure before you optimize
The most common Node.js performance mistake is guessing. Engineers add caches, rewrite queries, or move to a different framework — without profiling first. Profile, find the bottleneck, fix the bottleneck, measure again. Repeat.
CPU profiling with --prof
# Run your app with profiling enabled
node --prof src/index.js
# Process the output
node --prof-process isolate-*.log > profile.txt
# Or use the V8 inspector
node --inspect src/index.js
# Open chrome://inspect in ChromeLook for hot functions taking more than 5% of CPU time. Focus optimization there, not on cold paths.
Flame graphs
Use 0x (npm install -g 0x) to generate interactive SVG flame graphs. These are far easier to read than raw profiler output and immediately show you where time is being spent.
0x -- node src/index.jsThe libuv thread pool
Node's thread pool handles file I/O, DNS lookups, and some crypto operations. The default pool size is 4. If your app does heavy concurrent file operations or DNS, increase it:
UV_THREADPOOL_SIZE=16 node src/index.jsCluster mode
Node is single-threaded per process, but you can spawn one worker per CPU core using the cluster module or a process manager like PM2.
import cluster from 'cluster';
import os from 'os';
if (cluster.isPrimary) {
const cpus = os.cpus().length;
for (let i = 0; i < cpus; i++) cluster.fork();
} else {
startServer();
}The four most common antipatterns
1. Synchronous operations on the hot path. fs.readFileSync, JSON.parse on large payloads, and crypto.pbkdf2Sync block the event loop for every concurrent request. Use async alternatives.
2. N+1 queries. Fetching a list of items and then querying each item's related data in a loop. Use JOINs, DataLoader, or batch queries.
3. No connection pooling. Creating a new database connection per request instead of reusing a pool. This alone can make an app 20x slower under load.
4. Memory leaks through event emitters. Adding listeners inside request handlers without removing them. Use emitter.setMaxListeners and always clean up in the request lifecycle.
Memory leak detection
# Take a heap snapshot
node --expose-gc src/index.js
# In your health endpoint:
import v8 from 'v8';
app.get('/heap', (req, res) => {
res.json(v8.getHeapStatistics());
});Monitor heap growth over time. Steady growth under constant load is a leak. Use Chrome DevTools heap snapshots to compare allocations between time points.
Caching correctly
In-process caching (a simple Map with TTL) is faster than Redis for frequently read, rarely changing data. Use Redis when you need shared state across replicas or TTL-based eviction at scale.
HTTP keep-alive
Node's http.Agent doesn't enable keep-alive by default for outgoing requests. This means a new TCP connection per request to upstream services. Enable it:
import http from 'http';
const agent = new http.Agent({ keepAlive: true });
fetch('https://api.example.com', { agent });