Performance

Find the slow part before changing anything, then use the four techniques that actually help.

17of 18
≈ 11Min read
7Parts

Measure first

Almost every "Node is slow" turns out to be one query without an index, one synchronous call, or one loop over a thousand rows doing a request each. Profile, and the guess is usually wrong.

a number beats an opinion
console.time("query"); await db.query(sql); console.timeEnd("query");

import { performance } from "node:perf_hooks";
const t = performance.now();
…
logger.info({ ms: performance.now() - t, route: req.path });

// node --cpu-prof app.js      → a .cpuprofile you open in Chrome DevTools
// node --inspect app.js       → attach the profiler live
// autocannon -c 50 -d 10 http://localhost:3000/doors   → load, honestly

Never block the loop

  • Event loop delay is the number to watch: perf_hooks.monitorEventLoopDelay().
  • A slow database query does not block the loop — it is waiting on the network. A slow loop does.
  • CPU work belongs in worker_threads, a child process, or a queue consumed elsewhere.
the things that stop everything
fs.readFileSync(big)              // → fs.promises.readFile
JSON.parse(hugeString)            // → stream it, or do it in a worker
crypto.pbkdf2Sync(…)              // → the async version
for (let i = 0; i < 1e9; i++) {}  // → a worker thread

// the symptom: every endpoint gets slow at once, including /healthz

Cache, with an expiry

in memory for one process, Redis for many
const cache = new Map();

async function getDoors() {
  const hit = cache.get("doors");
  if (hit && hit.until > Date.now()) return hit.value;

  const value = await db.query("SELECT * FROM doors");
  cache.set("doors", { value, until: Date.now() + 60_000 });
  return value;
}
A Map is not a cache

Without an expiry and a size limit it is a memory leak with good manners. Use lru-cache, or put a bound on it yourself — and remember an in-process cache is per instance, so five instances mean five copies and five different answers.

Do not ask twice

deduplication, and batching
// ten requests for the same door at once → one query
const inFlight = new Map();

function load(id) {
  if (inFlight.has(id)) return inFlight.get(id);

  const promise = db.byId(id).finally(() => inFlight.delete(id));
  inFlight.set(id, promise);
  return promise;
}

// and the N+1 fix: collect the ids, then one query
const rows = await db.query("SELECT * FROM doors WHERE id = ANY($1)", [ids]);

Limit concurrency

The right number is usually close to your database pool size. Beyond it you are only queueing somewhere less visible.

parallel is not a synonym for fast
// 5 000 connections at once — the database falls over, not the loop
await Promise.all(ids.map(load));

// a handful at a time
import pLimit from "p-limit";
const limit = pLimit(10);
await Promise.all(ids.map((id) => limit(() => load(id))));

The cheap wins on an HTTP server

  • Compression (compression, or at the proxy) — the biggest single win on JSON.
  • Keep-alive on outgoing requests: an Agent with keepAlive: true saves a handshake per call.
  • Cache-Control and ETag so clients and CDNs stop asking.
  • Select the columns you need, and paginate. SELECT * on a wide table is bytes you serialise and throw away.
  • Stream a large response instead of building the whole string.

Scaling out

Once you run more than one process, nothing stateful may live in memory: sessions, caches, counters and rate limits all move to Redis or the database. Designing for that from the start costs nothing; retrofitting it costs a weekend.

one process uses one core
// node:cluster, or — in practice — run N containers behind a load balancer
import cluster from "node:cluster";
import os from "node:os";

if (cluster.isPrimary) {
  for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
} else {
  startServer();
}

Practice