#What to measure
Performance work goes wrong when you start with a fix ("add Redis", "use cluster") instead of a measurement. A Node service can be slow for very different reasons, and each has a different cure. Measure, name the bottleneck, change one thing, measure again.
A useful mental model is a restaurant with one chef (the main JavaScript thread) and many waiters (libuv, the OS, the database). A slow evening can mean the chef is chopping too much (CPU-bound JavaScript), the chef is stuck on one huge order and everyone else waits (event-loop blocking), the suppliers are late (slow database or downstream API), or the kitchen is full of dirty dishes (memory and garbage-collection pressure). More chefs, faster suppliers and a bigger dishwasher are different purchases. You find out which one you need by watching the kitchen, not by guessing.
The numbers that matter:
- Latency percentiles — p50 is the typical request; p95 and p99 are the slow tail that users and timeouts actually feel. Averages hide the tail.
- Throughput — requests or jobs completed per second at an acceptable latency.
- Event-loop delay and utilisation — how late callbacks run, and how busy the main thread is (next topic).
- CPU — per process, and for the host or container (a CPU-throttled container looks like a slow app).
- Memory and GC —
heapUsedafter GC over time, RSS, GC pause times. - Downstream timing — how long each database query or HTTP call takes, measured from your side.
Here's why averages lie. createHistogram() from node:perf_hooks is the same histogram type Node uses for event-loop delay, and you can record your own values into it:
const { createHistogram } = require('node:perf_hooks');
const h = createHistogram(); // records integers; we'll record milliseconds
// 1,000 requests: most are quick, 2% hit a slow path (a cold cache, a lock, a GC pause)
for (let i = 0; i < 1_000; i++) h.record(i % 50 === 0 ? 2_000 : 40 + (i % 7));
console.log({
count: h.count,
mean: h.mean.toFixed(1),
p50: h.percentile(50),
p95: h.percentile(95),
p99: h.percentile(99),
max: h.max,
});
// { count: 1000, mean: '82.1', p50: 43, p95: 46, p99: 2000, max: 2000 }- The loop records 1,000 fake latencies. Every 50th request (
i % 50 === 0, so 20 of them) takes 2,000 ms; the other 980 take between 40 and 46 ms. h.meanis82.1ms. That looks like a healthy service — but no request actually took 82 ms. The mean is dragged up by 20 very slow requests and describes nobody.p50is43: half the requests finished in 43 ms or less.p95is46: 95% were still fast.p99is2000: the slowest 1% (and here 2%) take two full seconds. If a page makes 10 API calls, the chance that at least one of them lands in that 2% is about 18% — so a "2% problem" hits almost one page view in five.- This is why dashboards and SLOs use p95/p99, and why a fix that improves the mean but not p99 may not help users at all.
From my notes, the quick answer to "how can you improve the performance of a Node.js application?" was: caching (Redis), clustering for multiple cores, asynchronous code so the event loop isn't blocked, optimised database queries with indexes, and response compression. Those are all real techniques, but each one fixes a different bottleneck class:
| Bottleneck | Symptom | Typical fix |
|---|---|---|
| CPU-bound JavaScript | high CPU, hot functions in a profile | better algorithm, fewer allocations, a worker pool |
| Event-loop blocking | high event-loop delay, every request slow at once | remove sync calls from hot paths, split or offload work |
| Downstream I/O | low CPU, long waits on DB/HTTP | indexes, query plans, connection pooling, concurrency, caching |
| Memory / GC | heapUsed climbing after GC, long GC pauses | fix retention, bound caches, stream instead of buffering |
| Serialisation and logging | CPU spent in JSON.stringify, logging | log less, don't serialise huge objects |
| Whole-server capacity | one process at 100% CPU under load | more processes or pods (cluster, Kubernetes) |