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.
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.
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
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;
}
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
// 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.
// 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
AgentwithkeepAlive: truesaves a handshake per call. Cache-ControlandETagso 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.
// 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();
}