LEVEL 17 · LESSON 3 OF 4

Production Production

Diagnosing performance

Find out why the Task API is slow before changing anything: load tests, event-loop lag, CPU profiles, query plans, pools, caches, workers and memory.

  • 60 min to read and try
  • You need: Production engineering, A production security review, The event loop, Indexes, Caching, and Background jobs
  • You build: A diagnosis of a slow Task API list endpoint from load test to CPU profile to fix, plus measured checks for queries, pools, hot keys, workers and memory
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Tell CPU-bound, I/O-bound and queueing slowness apart from their symptoms before touching code
  • Load-test with autocannon, read event-loop delay, and find the hot function in a CPU profile
  • Count queries and read EXPLAIN ANALYZE output to find N+1 queries, missing indexes and deep OFFSETs
  • Size pools and worker concurrency with Little's law and measure where a bigger pool stops helping
  • Find hot cache keys and bound memory, and write performance tests that assert counts instead of milliseconds

The list that got slower every week

At launch, GET /api/v1/tasks answered in about 30 ms. Six weeks later the dashboard shows the 95th percentile (p95) at more than a second, and at busy times even /health is slow. The team has ideas:

  • "Put Redis in front of it."
  • "Add two more servers."
  • "PostgreSQL is slow, we need a bigger database."
  • "Node.js can't handle this, rewrite the endpoint in Go."

Every one of these costs days, adds moving parts, and may do nothing, because nobody knows yet where the time goes. Performance work has one rule: diagnose, don't guess. Measure, find the part that is slow, change one thing, and measure again. Most of the time the cause is something small and specific (an unpaginated list, a missing index, a loop of queries) that none of the big guesses would have fixed.

This lesson diagnoses the slow list for real, on a project generated by the zudojs CLI, and then works through the other usual suspects: the database, connection pools, caches, workers, middleware and memory. Two notes on the examples:

  • The <shell> blocks were run on a laptop. Your numbers will be different; what matters is the comparison inside each block.
  • The runnable examples never print raw timings, which change on every run. They print comparisons ("at least 5 times slower") and facts that do not change (bytes, row counts, query counts).

Where the time goes

From the outside, a request just "takes 800 ms". Inside a Node.js server, that time is spent in one of three ways:

The request is…Typical causesWhat you see
On the CPU in your processSerializing huge responses, sorting, hashing, regex, a slow libraryHigh event-loop delay; every route gets slow, even /health; one CPU core near 100%
Waiting for I/OA slow query, a slow API, a cache round trip per itemEvent loop is fine; only routes that touch that dependency are slow; the dependency's own metrics show it
Waiting in a queue for a limited resourceConnection pool full, worker concurrency, a rate limit, the libuv thread poolLatency grows with traffic while each operation stays fast; "waiting" counts rise

A good first question for every resource (CPU, pool, database, queue) is the USE check: how busy is it (Utilization), how much work is waiting for it (Saturation), and is it failing (Errors)?

REASON IT OUT

Read the symptoms before the code

Before opening any file, decide what each observation suggests:

  1. p95 of the task list went from 30 ms to 1,100 ms, and /health also got slower during busy hours.
  2. The database's own slow-query log shows nothing over 5 ms.
  3. The response of GET /api/v1/tasks has grown to 1.4 MB.
  4. Traffic doubled over the same six weeks.

Which kind of slowness is it? Which of the team's four ideas would help, and what should you measure first?

Show the reasoning
  1. /health does no work at all. If it slows down, requests are waiting for the one thing all routes share: the event loop. That points at CPU work in the process.
  2. So it is not the database. A bigger database, and probably Redis in front of it, would change nothing.
  3. 1.4 MB of JSON per request is a lot of JSON.stringify, and a lot of bytes to write. Something that grows with the data (every task in the table) instead of with the page size is a classic cause.
  4. Doubled traffic makes CPU problems worse, but it does not explain a 35 times slower response by itself.

More servers would spread the CPU work and help a little, at double the cost, until the list grows again. The first measurement is a load test of the list next to /health while watching event-loop delay, then a CPU profile. The rest of the lesson does exactly that.

Measure the event loop

Everything in a Node.js server runs on one event loop (the event loop lesson). While one request's code runs, no other request's code can. Event-loop delay (or lag) measures how late the loop gets to scheduled work: near zero when it is idle, hundreds of milliseconds when something hogs the CPU. Node measures it with monitorEventLoopDelay from node:perf_hooks.

Here is the problem reduced to one file: a ZudoJS server with 10,000 tasks, a route that returns all of them, one that returns a page of 50, and /health. The script sends 100 requests to one list route, 10 at a time, while it keeps asking /health, and records the loop delay:

lag.tsNode.js only
import { monitorEventLoopDelay } from "node:perf_hooks";
import { createHttpServer, createNodeHttpAdapter, createResponseContext, createRouter } from "@zudojs/http";

const tasks = Array.from({ length: 10_000 }, (_, i) => ({
  id: `t-${i}`, ownerId: "u-ada", title: `Task number ${i}`, done: i % 3 === 0, createdAt: "2026-09-01T09:00:00.000Z",
}));
const router = createRouter();
router.get("/health", () => createResponseContext().json({ status: "ok" }));
router.get("/tasks/all", () => createResponseContext().json(tasks));
router.get("/tasks/page", () => createResponseContext().json(tasks.slice(0, 50)));
const server = createHttpServer({
  adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 0, keepAliveTimeout: 60_000 }),
  handler: async (request) => (await router.dispatch(request)).response,
});
await server.start();
const base = `http://127.0.0.1:${server.address?.port}`;

async function timed(path: string): Promise<number> {
  const start = performance.now();
  await (await fetch(base + path)).arrayBuffer();
  return performance.now() - start;
}
const median = (values: number[]) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)]!;

async function scenario(path: string) {
  const delay = monitorEventLoopDelay({ resolution: 5 });
  delay.enable();
  const health: number[] = [];
  let loading = true;
  const probe = (async () => {
    while (loading) {
      health.push(await timed("/health"));
      await new Promise((resolve) => setTimeout(resolve, 5));
    }
  })();
  const latencies: number[] = [];
  let sent = 0;
  await Promise.all(Array.from({ length: 10 }, async () => {
    while (sent++ < 100) latencies.push(await timed(path));
  }));
  loading = false;
  await probe;
  delay.disable();
  return { median: median(latencies), healthMedian: median(health), delayP90: delay.percentile(90) / 1e6 };
}

await scenario("/tasks/page");
const all = await scenario("/tasks/all");
const page = await scenario("/tasks/page");
console.log("full list median at least 3x the page median:", all.median > 3 * page.median);
console.log("/health median at least 2x slower while the full list is served:", all.healthMedian > 2 * page.healthMedian);
console.log("event-loop delay (p90) at least 2x higher:", all.delayP90 > 2 * page.delayP90);
await server.stop();
Output of npx tsx lag.ts
full list median at least 3x the page median: true
/health median at least 2x slower while the full list is served: true
event-loop delay (p90) at least 2x higher: true

The first scenario run is a warm-up, so the measured runs do not include one-time costs such as compiling code. The comparisons use medians, which one unlucky request cannot move, and the thresholds are low on purpose so the result holds on a busy machine; on a quiet one the differences are 10 to 20 times. (keepAliveTimeout is raised so that, on a slow machine, the client never reuses a connection at the moment the server closes it as idle, which would fail with ECONNRESET.) The three lines say the same thing: while full lists are being served, everything waits, including the route that does nothing. That is the signature of CPU work on the event loop, not of a slow database.

MEASURE FROM OUTSIDE

This script sends the requests from the same process as the server, so the client's work also lands on the loop. That is fine for a comparison between two routes, but for real numbers use a separate load-testing process, as in the next section.

In production you do not run scripts: you keep the delay measured all the time. A few lines in the generated src/server.ts, after await server.start(), log it every 10 seconds. In a real deployment, record the same numbers in a histogram of @zudojs/observability instead of logging them:

src/server.ts (part)Node.js only
import { monitorEventLoopDelay } from "node:perf_hooks";

// How late the event loop runs, every 10 s: near 0 when idle, high when code hogs the CPU.
// The histogram measures the time between samples, so subtract the sampling interval.
const SAMPLE_MS = 10;
const loopDelay = monitorEventLoopDelay({ resolution: SAMPLE_MS });
loopDelay.enable();
setInterval(() => {
  const late = (ns: number) => Math.max(0, Math.round(ns / 1e6) - SAMPLE_MS);
  console.log(`event loop delay: p50=${late(loopDelay.percentile(50))}ms p99=${late(loopDelay.percentile(99))}ms max=${late(loopDelay.max)}ms`);
  loopDelay.reset();
}, 10_000).unref();

Mind the subtraction. The histogram records the time between samples, so an idle loop sampled every 10 ms reports about 10 ms, not 0. unref() lets the process exit even though the interval is still scheduled.

Load-test the real app

A load test sends many requests at once and reports latency percentiles and throughput. autocannon is a load tester written in Node.js; npx autocannon runs it without installing it into the project. First build the Task API (with the tasks resource from zudojs generate resource tasks) and start it on its own port, then create tasks by sending 2,000 POST requests:

Terminal 1: the app (example output)
npm run build
PORT=3100 node dist/server.js
…
Listening on http://0.0.0.0:3100
Terminal 2: the load (example output)
npx autocannon -m POST -H content-type=application/json -b '{"name":"Buy milk"}' -a 2000 http://localhost:3100/api/v1/tasks
Running 2000 requests test @ http://localhost:3100/api/v1/tasks
10 connections
…
300 2xx responses, 1700 non 2xx responses
2k requests in 3.06s, 1.95 MB read

Only 300 requests succeeded. The rest got 429: the generated project rate-limits every client to RATE_LIMIT_MAX=300 requests per minute, and a load tester is one client. Always check the status counts before reading a single latency number; a test that measures 429 responses measures the rate limiter. For the load-test copy, raise the limit, and remember that choice when you read the profile later:

Terminal 1 (example output)
PORT=3100 RATE_LIMIT_MAX=1000000 node dist/server.js
Terminal 2 (example output)
npx autocannon -m POST -H content-type=application/json -b '{"name":"Buy milk"}' -a 10000 http://localhost:3100/api/v1/tasks
…
10k requests in 20.07s, 9.3 MB read
npx autocannon -c 20 -d 10 http://localhost:3100/health
Running 10s test @ http://localhost:3100/health
20 connections

┌─────────┬──────┬───────┬───────┬───────┬─────────┬──────────┬────────┐
│ Stat    │ 2.5% │ 50%   │ 97.5% │ 99%   │ Avg     │ Stdev    │ Max    │
├─────────┼──────┼───────┼───────┼───────┼─────────┼──────────┼────────┤
│ Latency │ 9 ms │ 18 ms │ 48 ms │ 60 ms │ 20.9 ms │ 10.33 ms │ 159 ms │
└─────────┴──────┴───────┴───────┴───────┴─────────┴──────────┴────────┘
┌───────────┬────────┬────────┬────────┬─────────┬────────┬────────┬────────┐
│ Stat      │ 1%     │ 2.5%   │ 50%    │ 97.5%   │ Avg    │ Stdev  │ Min    │
├───────────┼────────┼────────┼────────┼─────────┼────────┼────────┼────────┤
│ Req/Sec   │ 685    │ 685    │ 826    │ 1,348   │ 932.4  │ 207.82 │ 685    │
…
9k requests in 10.06s, 7.92 MB read
npx autocannon -c 20 -d 10 http://localhost:3100/api/v1/tasks
Running 10s test @ http://localhost:3100/api/v1/tasks
20 connections

┌─────────┬────────┬────────┬────────┬────────┬───────────┬───────────┬─────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev     │ Max     │
├─────────┼────────┼────────┼────────┼────────┼───────────┼───────────┼─────────┤
│ Latency │ 152 ms │ 424 ms │ 901 ms │ 988 ms │ 459.76 ms │ 159.73 ms │ 1378 ms │
└─────────┴────────┴────────┴────────┴────────┴───────────┴───────────┴─────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ Stat      │ 1%      │ 2.5%    │ 50%     │ 97.5%   │ Avg     │ Stdev   │ Min     │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Req/Sec   │ 30      │ 30      │ 41      │ 53      │ 42.1    │ 7.94    │ 30      │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Bytes/Sec │ 42.6 MB │ 42.6 MB │ 58.3 MB │ 75.4 MB │ 59.8 MB │ 11.3 MB │ 42.6 MB │
└───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
…
441 requests in 10.08s, 598 MB read
curl -s http://localhost:3100/api/v1/tasks | wc -c
1420001

-c 20 keeps 20 connections busy and -d 10 runs for 10 seconds. Read the two tables side by side: /health answered about 900 requests a second with a median (p50) of 18 ms; the task list managed about 42 a second with a median of 424 ms and a p99 near one second, while moving about 60 MB of JSON per second. Each response is 1.4 MB: every one of the 10,000 tasks, every time.

Meanwhile, terminal 1 printed the event-loop delay lines added above. The one from the /health run and the one from the list run:

Terminal 1 (example output)
event loop delay: p50=6ms p99=37ms max=75ms
event loop delay: p50=0ms p99=469ms max=669ms

A p99 delay of almost half a second means some requests waited that long just to get their turn on the CPU. The load test and the delay agree with the scaled-down experiment: the time is CPU work inside the process.

WHAT ABOUT CLINIC.JS?

Clinic.js (clinic doctor, clinic flame) used to be the standard way to get this diagnosis as a report. Its last release, 13.0.0, does not work on Node.js 24: npx clinic doctor --on-port "…" -- node dist/server.js printed a deprecation warning, exited with code 0, and wrote no report. Node's built-in profiler, next, does the job with nothing to install.

Find the hot function with a CPU profile

A CPU profiler samples the call stack thousands of times a second and records which function was running. Self time is the time spent in a function's own code, not in the functions it calls; the functions with the most self time are the hot spots. Node.js writes a profile when you start it with --cpu-prof, as soon as the process exits. The debugging tools lesson shows how to open one in Chrome DevTools; here a small script prints the top functions instead:

top-functions.mjs
// Usage: node top-functions.mjs profiles/CPU.….cpuprofile
import { readFileSync } from "node:fs";
import { basename } from "node:path";

const profile = JSON.parse(readFileSync(process.argv[2], "utf8"));
const byId = new Map(profile.nodes.map((node) => [node.id, node]));
const selfTime = new Map();
profile.samples.forEach((id, i) => {
  const { functionName, url, lineNumber } = byId.get(id).callFrame;
  const name = `${functionName || "(anonymous)"} ${url ? `${basename(url)}:${lineNumber + 1}` : ""}`.trim();
  selfTime.set(name, (selfTime.get(name) ?? 0) + (profile.timeDeltas[i] ?? 0));
});
const total = [...selfTime.values()].reduce((a, b) => a + b, 0);
for (const [name, time] of [...selfTime].sort((a, b) => b[1] - a[1]).slice(0, 8)) {
  console.log(`${((time / total) * 100).toFixed(1).padStart(5)}%  ${name}`);
}
Profile the list under load (example output)
PORT=3100 RATE_LIMIT_MAX=1000000 node --cpu-prof --cpu-prof-dir=profiles dist/server.js
# In terminal 2: create 10,000 tasks and run autocannon on the list, then press Ctrl+C here.
ls profiles
CPU.20260925.004555.2425821.0.001.cpuprofile
node top-functions.mjs profiles/*.cpuprofile
 25.1%  json http.js:4
 10.0%  check rateLimit.core.js:143
  7.8%  (idle)
  7.2%  writev
  5.2%  writeUtf8String
  3.8%  encodeUtf8String
  2.6%  (anonymous) rateLimit.core.js:168
  2.3%  (garbage collector)

The top entry is the json helper in dist/utils/http.js, line 4, which the generated controllers use to build every JSON response. It calls JSON.stringify, whose time is counted in its caller. writev, writeUtf8String and encodeUtf8String are the cost of turning those strings into bytes and writing them to the sockets. Together that is about 40% of all CPU time, spent turning 10,000 tasks into text on every request. The second entry is a surprise, and gets its own section below.

Serialization cost grows with the size of what you serialize. Measure the two response shapes directly:

serialization-cost.ts
const tasks = Array.from({ length: 10_000 }, (_, i) => ({
  id: crypto.randomUUID(), name: `Task number ${i}`, createdAt: "2026-09-01T09:00:00.000Z", updatedAt: "2026-09-01T09:00:00.000Z",
}));
const bytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).length;

function msFor(value: unknown, runs: number): number {
  const start = performance.now();
  for (let run = 0; run < runs; run++) JSON.stringify(value);
  return (performance.now() - start) / runs;
}
msFor(tasks, 5);
const full = msFor(tasks, 20);
const page = msFor({ items: tasks.slice(0, 50), next: tasks[49]!.id }, 2_000);

console.log("full list bytes:", bytes(tasks));
console.log("page of 50 bytes:", bytes({ items: tasks.slice(0, 50), next: tasks[49]!.id }));
console.log("stringify of the full list at least 50x slower:", full > 50 * page);
Output of npx tsx serialization-cost.ts and of the browser terminal
full list bytes: 1498891
page of 50 bytes: 7447
stringify of the full list at least 50x slower: true

The fix: paginate

No client can show 10,000 tasks at once, so the fix is to stop sending them: return a page, and a cursor for the next one. The generated project already validates input with schemas, so the query gets one too, with a hard maximum:

src/dtos/tasks.dto.ts (part)Node.js only
/** Query of `GET /api/v1/tasks`: at most 100 per page, 50 by default. */
export const ListTasksQuerySchema = schema.object({
  limit: schema.coerce.number().int().min(1).max(100).default(50),
  after: schema.string().uuid().optional(),
});
src/controllers/tasks.controller.ts (part)Node.js only
public readonly list = async (ctx: HttpRouterContext): Promise<HttpResponseContext> => {
  const query = ListTasksQuerySchema.safeParse(ctx.query);
  if (!query.success) return validationFailed(query.issues);
  const items = await this.service.list(query.data.limit, query.data.after);
  const next = items.length === query.data.limit ? items.at(-1)?.id : undefined;
  return json(200, { items, next });
};

The service passes limit and after through to a new findPage(limit, after) in the repository. For the in-memory repository that is a loop over the map; with PostgreSQL it is the keyset query in the database section. Rebuild, restart, and repeat the same test:

After the fix (example output)
curl -s "http://localhost:3100/api/v1/tasks?limit=1000"
{"error":"Validation failed","issues":[{"path":"limit","message":"Expected <= 100, received 1000"}]}
curl -s http://localhost:3100/api/v1/tasks | wc -c
7157
npx autocannon -c 20 -d 10 http://localhost:3100/api/v1/tasks
Running 10s test @ http://localhost:3100/api/v1/tasks
20 connections

┌─────────┬──────┬───────┬───────┬───────┬──────────┬─────────┬────────┐
│ Stat    │ 2.5% │ 50%   │ 97.5% │ 99%   │ Avg      │ Stdev   │ Max    │
├─────────┼──────┼───────┼───────┼───────┼──────────┼─────────┼────────┤
│ Latency │ 7 ms │ 16 ms │ 42 ms │ 50 ms │ 17.48 ms │ 8.88 ms │ 103 ms │
└─────────┴──────┴───────┴───────┴───────┴──────────┴─────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ Stat      │ 1%      │ 2.5%    │ 50%     │ 97.5%   │ Avg     │ Stdev   │ Min     │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Req/Sec   │ 716     │ 716     │ 1,101   │ 1,624   │ 1,111.5 │ 277.8   │ 716     │
…
11k requests in 10.07s, 88.3 MB read

From about 42 to about 1,100 requests a second, median from 424 ms to 16 ms, and the event-loop delay p99 in terminal 1 dropped to 38 ms. A client asking for 1,000 items gets a 400 instead of a slow response. Nothing else changed: no cache, no new server.

The next hot spot was the test

Profile again after a fix, because the ranking changes. With the list fixed, the top of the profile looked like this, and then like this after restarting with the production limit of 300:

Profiles after the fix (example output)
node top-functions.mjs profiles/*.cpuprofile        # RATE_LIMIT_MAX=1000000
 21.5%  check rateLimit.core.js:143
  7.6%  writeUtf8String
  7.4%  (idle)
…
node top-functions.mjs profiles/*.cpuprofile        # RATE_LIMIT_MAX=300
 14.3%  writeUtf8String
 11.8%  (idle)
  9.0%  writev
  3.6%  (program)
  2.9%  check rateLimit.core.js:143
…

The rate limiter from @zudojs/security keeps a list of timestamps per client (a sliding log) and copies that list on every check, so each check costs time proportional to the requests that client made in the window. With a limit of 300 the list never grows past 300; with a limit of a million and every request coming from one load tester, it grows with the test:

rate-limiter-cost.tsNode.js only
import { createRateLimiter } from "@zudojs/security";

function lastThousandMs(max: number, checks: number): number {
  const limiter = createRateLimiter({ windowMs: 60_000, max });
  for (let i = 0; i < checks - 1_000; i++) limiter.check({ ip: "203.0.113.7" });
  const start = performance.now();
  for (let i = 0; i < 1_000; i++) limiter.check({ ip: "203.0.113.7" });
  const ms = performance.now() - start;
  limiter.destroy();
  return ms;
}

lastThousandMs(300, 8_000);
const production = lastThousandMs(300, 8_000);
const loadTest = lastThousandMs(1_000_000, 8_000);
const productionLater = lastThousandMs(300, 16_000);
console.log("after 8,000 checks from one client, a limit of 1,000,000 costs at least 5x a limit of 300:", loadTest > 5 * production);
console.log("with a limit of 300, twice as many earlier checks cost no more than 2x:", productionLater < 2 * production);
Output of npx tsx rate-limiter-cost.ts
after 8,000 checks from one client, a limit of 1,000,000 costs at least 5x a limit of 300: true
with a limit of 300, twice as many earlier checks cost no more than 2x: true

So the second hot spot was produced by the test setup, not by production traffic, where thousands of clients each stay under 300. Two lessons: make the load test look like production (many client addresses, production settings), and know your tools' cost model. If you ever need a high per-client limit, such as for a trusted internal client, prefer a counter-based limiter (a fixed window or token bucket in Redis, from the rate limiting lesson) over a sliding log.

Middleware and serialization cost

Every middleware runs on every request, so its cost is multiplied by your whole traffic. Most are cheap. The expensive ones do work proportional to the body: logging the full response, deep-cloning it, validating a large response against a schema, compressing it. Measure a pipeline with and without a logger that parses the response body (to redact fields, say) and writes it into a log line:

middleware-cost.tsNode.js only
import { HttpMiddlewarePipeline, createRequestContext, createResponseContext, type HttpMiddleware } from "@zudojs/http";

const tasks = Array.from({ length: 2_000 }, (_, i) => ({ id: i, title: `Task number ${i}`, done: false }));
const handler: HttpMiddleware = async () => createResponseContext().json(tasks);
const logged: string[] = [];
const logFullBody: HttpMiddleware = async (_context, next) => {
  const response = await next();
  const body: unknown = JSON.parse(String(response.body));
  logged.push(JSON.stringify({ status: response.status, body }));
  logged.length = 0;
  return response;
};

const request = createRequestContext({ method: "GET", url: "http://localhost/tasks" });
async function msFor50(pipeline: HttpMiddlewarePipeline): Promise<number> {
  const start = performance.now();
  for (let i = 0; i < 50; i++) await pipeline.execute(request, createResponseContext());
  return performance.now() - start;
}

const plain = new HttpMiddlewarePipeline({ middlewares: [handler] });
const withLogging = new HttpMiddlewarePipeline({ middlewares: [logFullBody, handler] });
const best = { plain: Infinity, withLogging: Infinity };
for (let round = 0; round < 6; round++) {
  best.plain = Math.min(best.plain, await msFor50(plain));
  best.withLogging = Math.min(best.withLogging, await msFor50(withLogging));
}
console.log("logging the full body at least 1.5x the cost of the request:", best.withLogging > 1.5 * best.plain);
Output of npx tsx middleware-cost.ts
logging the full body at least 1.5x the cost of the request: true

The script alternates the two pipelines and keeps each one's best round: the fastest run is the one least disturbed by garbage collection and other programs, which makes a comparison stable. createResponseContext().json(tasks) has already turned the tasks into a string, so the logger parses and serializes the same data a second and third time, on every request. Log the facts about a response (status, size, duration, request id) instead of the response. The same applies to response validation: validate in tests and in staging, and only sample it in production if the bodies are large.

To see where time goes per request from the outside, @zudojs/http has createTimingMiddleware(). It adds a Server-Timing header, which the browser's Network panel shows as a timing bar:

server-timing.tsNode.js only
import { HttpMiddlewarePipeline, createRequestContext, createResponseContext, createTimingMiddleware } from "@zudojs/http";

const pipeline = new HttpMiddlewarePipeline({
  middlewares: [createTimingMiddleware(), async () => createResponseContext().json({ status: "ok" })],
});
const response = await pipeline.execute(createRequestContext({ method: "GET", url: "http://localhost/health" }), createResponseContext());
const header = response.headers["server-timing"];
console.log("Server-Timing looks like total;dur=<ms>:", /^total;dur=\d+\.\d{2}$/.test(String(header)));
Output of npx tsx server-timing.ts
Server-Timing looks like total;dur=<ms>: true

Put it first in the pipeline to time everything after it. On a public API, consider sending it only to internal clients: timings help an attacker tell cached from uncached answers.

The database: count queries, then read the plan

When the event loop is quiet and a route is still slow, the time is usually in the database. Two measurements find most problems: how many queries a request makes, and how many rows each query reads to return what it returns. The examples use PGlite, the in-process PostgreSQL from the database lesson.

Count queries: the N+1 problem

The task board shows 50 tasks with the name of each assignee. The first version loads the tasks, then asks for each assignee separately. A counting wrapper around query shows it immediately:

n-plus-one.tsNode.js only
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
await db.exec(`
  CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);
  CREATE TABLE tasks (id SERIAL PRIMARY KEY, owner_id TEXT NOT NULL, assignee_id TEXT REFERENCES users(id), title TEXT NOT NULL);
  INSERT INTO users SELECT 'u-' || g, 'User ' || g FROM generate_series(1, 200) g;
  INSERT INTO tasks (owner_id, assignee_id, title) SELECT 'u-1', 'u-' || (1 + g % 200), 'Task ' || g FROM generate_series(1, 5000) g;
`);
let queries = 0;
function query<T>(sql: string, params: unknown[] = []) {
  queries += 1;
  return db.query<T>(sql, params);
}

async function boardOneByOne(ownerId: string) {
  const { rows } = await query<{ id: number; title: string; assignee_id: string }>(
    "SELECT id, title, assignee_id FROM tasks WHERE owner_id = $1 ORDER BY id LIMIT 50", [ownerId]);
  return Promise.all(rows.map(async (task) => {
    const user = await query<{ name: string }>("SELECT name FROM users WHERE id = $1", [task.assignee_id]);
    return { id: task.id, title: task.title, assignee: user.rows[0]!.name };
  }));
}

async function boardJoined(ownerId: string) {
  const { rows } = await query<{ id: number; title: string; assignee: string }>(
    `SELECT t.id, t.title, u.name AS assignee FROM tasks t JOIN users u ON u.id = t.assignee_id
     WHERE t.owner_id = $1 ORDER BY t.id LIMIT 50`, [ownerId]);
  return rows;
}

queries = 0;
const slow = await boardOneByOne("u-1");
console.log("one by one:", slow.length, "tasks,", queries, "queries");
queries = 0;
const fast = await boardJoined("u-1");
console.log("joined:", fast.length, "tasks,", queries, "query");
console.log("same result:", JSON.stringify(slow) === JSON.stringify(fast));
await db.close();
Output of npx tsx n-plus-one.ts
one by one: 50 tasks, 51 queries
joined: 50 tasks, 1 query
same result: true

One query for the list plus one per row: N+1. In-process with PGlite each query is fast, which is exactly why N+1 hides in development. Over a network each query costs a round trip of a millisecond or more, plus a trip through the connection pool, so 51 queries cost 51 round trips per page view. The count is what you watch: in development, log the number of queries per request, and in tests, assert it (see budgets).

Read the plan with EXPLAIN ANALYZE

EXPLAIN shows the plan PostgreSQL chose; EXPLAIN ANALYZE also runs the query and reports what really happened. The indexes lesson explains every plan node. For diagnosis, turn off everything that changes between runs (COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) and compare rows: rows read versus rows returned:

explain.tsNode.js only
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
await db.exec(`
  CREATE TABLE tasks (id SERIAL PRIMARY KEY, owner_id TEXT NOT NULL, title TEXT NOT NULL);
  INSERT INTO tasks (owner_id, title) SELECT 'u-' || (g % 500), 'Task ' || g FROM generate_series(1, 50000) g;
  ANALYZE tasks;
`);
async function plan(label: string, sql: string, params: unknown[] = []) {
  const { rows } = await db.query<{ "QUERY PLAN": string }>(
    `EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) ${sql}`, params);
  console.log(label);
  for (const row of rows) console.log("  " + row["QUERY PLAN"]);
}

const page = "SELECT id, title FROM tasks WHERE owner_id = $1 ORDER BY id LIMIT 50";
await plan("1. one user's first page, no index:", page, ["u-42"]);
await db.exec("CREATE INDEX tasks_owner_id_id_idx ON tasks (owner_id, id)");
await plan("2. the same, with an index on (owner_id, id):", page, ["u-42"]);
await plan("3. page 801 of everything with OFFSET:", "SELECT id, title FROM tasks ORDER BY id LIMIT 50 OFFSET 40000");
await plan("4. the same page with a keyset cursor:", "SELECT id, title FROM tasks WHERE id > $1 ORDER BY id LIMIT 50", [40000]);
await db.close();
Output of npx tsx explain.ts
1. one user's first page, no index:
  Limit (actual rows=50.00 loops=1)
    ->  Index Scan using tasks_pkey on tasks (actual rows=50.00 loops=1)
          Filter: (owner_id = 'u-42'::text)
          Rows Removed by Filter: 24492
          Index Searches: 1
2. the same, with an index on (owner_id, id):
  Limit (actual rows=50.00 loops=1)
    ->  Index Scan using tasks_owner_id_id_idx on tasks (actual rows=50.00 loops=1)
          Index Cond: (owner_id = 'u-42'::text)
          Index Searches: 1
3. page 801 of everything with OFFSET:
  Limit (actual rows=50.00 loops=1)
    ->  Index Scan using tasks_pkey on tasks (actual rows=40050.00 loops=1)
          Index Searches: 1
4. the same page with a keyset cursor:
  Limit (actual rows=50.00 loops=1)
    ->  Index Scan using tasks_pkey on tasks (actual rows=50.00 loops=1)
          Index Cond: (id > 40000)
          Index Searches: 1

Read the actual rows and Rows Removed by Filter:

  1. Without an index, PostgreSQL walked the primary key in id order and threw away tens of thousands of other users' rows to find 50 of Ada's. The work grows with the whole table.
  2. With a composite index on (owner_id, id) it read exactly the 50 rows it returned, already in order. The work now grows with the page size.
  3. OFFSET 40000 makes the database produce 40,050 rows and discard 40,000 of them. Deep pages get slower and slower.
  4. A keyset (cursor) query, WHERE id > $last ORDER BY id, starts right after the last row of the previous page and reads 50 rows, on page 1 and on page 801 alike. That is what the after cursor in the Task API's pagination is for.

In production, pg_stat_statements tells you which queries use the most total time; start there instead of guessing (finding the slow queries).

Connection pools and limits

A connection pool is a queue in front of a limited resource (production engineering and connections and pools introduced them). How big should it be? Little's law gives the starting point: the average number of requests in a system equals the arrival rate times the average time each one spends there.

littles-law.ts
function connectionsNeeded(requestsPerSecond: number, queriesPerRequest: number, queryMs: number): number {
  return Math.ceil(requestsPerSecond * queriesPerRequest * (queryMs / 1000));
}

console.log("board, joined query:", connectionsNeeded(400, 1, 5), "connections busy on average");
console.log("board, N+1 (51 queries):", connectionsNeeded(400, 51, 5), "connections busy on average");
console.log("board, 1 slow query of 120 ms:", connectionsNeeded(400, 1, 120), "connections busy on average");
Output of npx tsx littles-law.ts and of the browser terminal
board, joined query: 2 connections busy on average
board, N+1 (51 queries): 102 connections busy on average
board, 1 slow query of 120 ms: 48 connections busy on average

At 400 requests a second, one 5 ms query per request keeps 2 connections busy on average. The N+1 version needs about 100, more than the whole database allows by default. So pool trouble is often a query problem wearing a disguise: fix the queries, and the pool you already have is big enough.

What happens when you make the pool bigger anyway? The database has a limited number of CPU cores too. This simulation has a database with 4 cores (a query takes 20 ms, longer when more than 4 run at once) and sends it 40 queries at the same moment through pools of different sizes:

pool-size.ts
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

function createDatabase(cores: number) {
  let active = 0;
  let slowestQuery = 0;
  return {
    get slowestQuery() { return slowestQuery; },
    async query(): Promise<void> {
      active += 1;
      const ms = 20 * Math.max(1, active / cores);
      slowestQuery = Math.max(slowestQuery, ms);
      await sleep(ms);
      active -= 1;
    },
  };
}

function createPool(size: number, db: ReturnType<typeof createDatabase>) {
  let free = size;
  let mostWaiting = 0;
  const waiting: Array<() => void> = [];
  return {
    get mostWaiting() { return mostWaiting; },
    async query(): Promise<void> {
      if (free > 0) free -= 1;
      else await new Promise<void>((resolve) => { waiting.push(resolve); mostWaiting = Math.max(mostWaiting, waiting.length); });
      try {
        await db.query();
      } finally {
        const next = waiting.shift();
        if (next) next();
        else free += 1;
      }
    },
  };
}

async function run(size: number) {
  const db = createDatabase(4);
  const pool = createPool(size, db);
  const start = performance.now();
  await Promise.all(Array.from({ length: 40 }, () => pool.query()));
  return { total: performance.now() - start, mostWaiting: pool.mostWaiting, slowestQuery: db.slowestQuery };
}

const base = await run(4);
for (const size of [2, 4, 8, 40]) {
  const result = size === 4 ? base : await run(size);
  console.log(`pool ${String(size).padStart(2)}: waited in pool ${String(result.mostWaiting).padStart(2)}, slowest query ${String(result.slowestQuery).padStart(3)} ms, total time x${(result.total / base.total).toFixed(0)} of pool 4`);
}
Output of npx tsx pool-size.ts and of the browser terminal
pool  2: waited in pool 38, slowest query  20 ms, total time x2 of pool 4
pool  4: waited in pool 36, slowest query  20 ms, total time x1 of pool 4
pool  8: waited in pool 32, slowest query  40 ms, total time x1 of pool 4
pool 40: waited in pool  0, slowest query 200 ms, total time x1 of pool 4

A pool of 2 is too small: twice the total time, with the queue in your app. From 4 upwards the total time stops improving, because the database has 4 cores. A pool of 40 has nobody waiting in the pool, which looks healthy on a dashboard, but each query takes 200 ms instead of 20: the queue moved into the database, where it is harder to see and slows everyone, including other services. The measurements to export are the pool's waiting count and time to acquire a connection (the pg package's Pool exposes waitingCount, totalCount and idleCount), next to the database's own CPU.

The connection limit is a budget: pool size × app processes, plus workers, plus the extra processes during a deployment, must stay under PostgreSQL's max_connections (100 by default). The sizing exercise in production engineering does that arithmetic; a pooler such as PgBouncer lets many app connections share few database connections when the budget runs out.

Caches and hot keys

Cache after you have measured that a read is slow and repeated; a cache in front of an N+1 query hides the problem until the cache is cold. Then measure the cache itself. @zudojs/cache keeps statistics, including the hot keys: the keys read most often. This access pattern is typical: the shared "launch week" board is read by everyone:

hot-keys.ts
import { createCacheService, createMemoryCacheAdapter } from "@zudojs/cache";

const cache = createCacheService({ adapter: createMemoryCacheAdapter({ maxEntries: 1_000 }), config: { defaultTtl: 60_000 } });
let databaseLoads = 0;
const load = async (key: string) => {
  databaseLoads += 1;
  return { key };
};

for (let request = 0; request < 1_000; request++) {
  const key = request % 5 < 3 ? "board.launch-week" : request % 5 === 3 ? `tasks.u-${request % 40}` : `task.${request}`;
  await cache.getOrSet(key, () => load(key));
}
console.log(cache.getHotKeys(3));
console.log("hit rate:", cache.getStats()?.hitRate, "database loads:", databaseLoads);
Output of npx tsx hot-keys.ts and of the browser terminal
[
  { key: 'zudojs:board.launch-week', hits: 599 },
  { key: 'zudojs:tasks.u-3', hits: 24 },
  { key: 'zudojs:tasks.u-8', hits: 24 }
]
hit rate: 0.791 database loads: 209

One key takes about 60% of all reads. (The zudojs: in front is the service's default namespace, added to every key.) In a single process that is fine, it is the best-cached key there is. With a shared cache such as Redis it is a hot key problem: every request of every app server goes to the one Redis node that owns that key, so that node's CPU and network saturate while the others idle. And when the entry expires, every server misses at once. Three fixes, in order of effort:

  • A small local cache in front of the shared one, with a short TTL. Each process asks Redis once per second instead of on every request.
  • Coalesce concurrent misses: getOrSet already does this within one process, as the caching lesson shows.
  • Spread expiry times with a little jitter, so keys written together do not expire together.
two-level-cache.ts
import { createCacheService, createMemoryCacheAdapter } from "@zudojs/cache";

let sharedReads = 0;
const shared = new Map<string, unknown>([["board.launch-week", { tasks: 12 }]]);
const readShared = async (key: string) => {
  sharedReads += 1;
  return shared.get(key);
};

const local = createCacheService({ adapter: createMemoryCacheAdapter({ maxEntries: 500 }) });
async function getBoard(): Promise<unknown> {
  const { value } = await local.getOrSet("board.launch-week", () => readShared("board.launch-week"), { ttl: 1_000 });
  return value;
}

await Promise.all(Array.from({ length: 200 }, () => getBoard()));
for (let i = 0; i < 800; i++) await getBoard();
console.log("1,000 requests, reads from the shared cache:", sharedReads);

/** Up to 10% extra, chosen by an FNV-1a hash of the key: the same key always gets the same TTL. */
function jitteredTtl(key: string, baseMs: number): number {
  let hash = 2166136261;
  for (const char of key) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619) >>> 0;
  return baseMs + (hash % 1_000) * (baseMs / 10_000);
}
for (const key of ["board.launch-week", "board.q4-planning", "board.support", "board.hiring"]) {
  console.log(key.padEnd(18), `ttl ${(jitteredTtl(key, 60_000) / 1000).toFixed(1)} s`);
}
Output of npx tsx two-level-cache.ts and of the browser terminal
1,000 requests, reads from the shared cache: 1
board.launch-week  ttl 62.5 s
board.q4-planning  ttl 60.5 s
board.support      ttl 61.9 s
board.hiring       ttl 64.5 s

The local layer turned 1,000 reads into 1 read of the shared cache, including the 200 that arrived at the same moment. The price is up to one second of staleness per process, which is fine for a board and wrong for a bank balance: decide per key. The jitter comes from a hash of the key, so each key gets the same TTL every time, but keys written in the same second expire up to six seconds apart.

Workers and concurrency

Background work (@zudojs/queue) has its own performance knob: concurrency, how many jobs one worker runs at once. Too low and the queue grows; too high and you only move the waiting somewhere else, just like the pool. Measure throughput as you raise it. The Task API sends reminder emails through a provider that allows 5 connections at a time and takes 40 ms per email:

worker-concurrency.ts
import { createInMemoryQueue, createQueueName } from "@zudojs/queue";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

function createMailProvider(connections: number) {
  let busy = 0;
  let queuedAtProvider = 0;
  const waiting: Array<() => void> = [];
  return {
    get queuedAtProvider() { return queuedAtProvider; },
    async send(): Promise<void> {
      if (busy < connections) busy += 1;
      else {
        queuedAtProvider += 1;
        await new Promise<void>((resolve) => waiting.push(resolve));
      }
      await wait(40);
      const next = waiting.shift();
      if (next) next();
      else busy -= 1;
    },
  };
}

async function run(concurrency: number) {
  const provider = createMailProvider(5);
  const queue = createInMemoryQueue<{ taskId: number }>(createQueueName(`reminders-${concurrency}`), { concurrency });
  queue.process("remind", () => provider.send());
  const start = performance.now();
  for (let taskId = 1; taskId <= 40; taskId++) await queue.add("remind", { taskId });
  while ((await queue.getStats()).succeeded < 40) await wait(5);
  const ms = performance.now() - start;
  await queue.close();
  return { ms, queuedAtProvider: provider.queuedAtProvider };
}

let previous = await run(1);
console.log(`concurrency  1: ${previous.queuedAtProvider} jobs waited at the provider`);
for (const [before, concurrency] of [[1, 2], [2, 5], [5, 10], [10, 20]] as const) {
  const result = await run(concurrency);
  const verdict = previous.ms > 1.4 * result.ms ? `faster than ${before}` : `no faster than ${before}`;
  console.log(`concurrency ${String(concurrency).padStart(2)}: ${verdict}, ${result.queuedAtProvider} jobs waited at the provider`);
  previous = result;
}
Output of npx tsx worker-concurrency.ts and of the browser terminal
concurrency  1: 0 jobs waited at the provider
concurrency  2: faster than 1, 0 jobs waited at the provider
concurrency  5: faster than 2, 0 jobs waited at the provider
concurrency 10: no faster than 5, 35 jobs waited at the provider
concurrency 20: no faster than 10, 35 jobs waited at the provider

Throughput grows with concurrency until it matches what the slowest thing downstream allows, then stops. Beyond 5, the extra jobs only wait at the provider, each holding memory and, with a real provider, a connection that may time out. Little's law says the same: 5 connections ÷ 0.04 s per email = 125 emails a second, whatever the concurrency. Set concurrency just at the knee, and scale by adding workers only when the downstream limit is per worker, not per account.

Watch two queue metrics in production: the depth (jobs waiting) and the age of the oldest waiting job. Depth alone is ambiguous; a deep queue that drains fast is fine, an old job means you are behind.

Memory

Memory problems show up as performance problems first: a growing heap makes garbage collection run longer and more often, and the (garbage collector) line in the CPU profile grows, until the process is killed for using too much. The memory lesson shows how to find a leak with heap snapshots. The most common cause in a service is a cache or map with no bound, keyed by something unbounded (request ids, user input, URLs):

bounded-memory.ts
import { createCacheService, createMemoryCacheAdapter } from "@zudojs/cache";

const unbounded = new Map<string, { kobo: number }>();
const adapter = createMemoryCacheAdapter({ maxEntries: 1_000 });
const bounded = createCacheService({ adapter, config: { defaultTtl: 300_000 } });

for (let request = 1; request <= 20_000; request++) {
  const key = `quote.req-${request}`;
  unbounded.set(key, { kobo: 950_000 });
  await bounded.set(key, { kobo: 950_000 });
}
console.log("unbounded map entries:", unbounded.size);
console.log("bounded cache entries:", await bounded.size());
Output of npx tsx bounded-memory.ts and of the browser terminal
unbounded map entries: 20000
bounded cache entries: 1000

The map grows with traffic forever; the cache stays at its limit, evicting old entries. For every in-memory structure, ask: what bounds its size? If the answer is "the number of requests", it is a leak with a delay. In production, watch heapUsed after garbage collection over hours, not minutes, and alert on growth.

Performance tests that do not flake

A test that asserts "responds in under 50 ms" fails on a slow CI machine and passes on a fast one, whatever the code does. Test the causes of slowness instead, which are exact: the number of queries per request, the number of rows read, the size of the response. These are performance budgets:

performance-budget.tsNode.js only
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
await db.exec(`
  CREATE TABLE tasks (id SERIAL PRIMARY KEY, owner_id TEXT NOT NULL, title TEXT NOT NULL);
  CREATE INDEX tasks_owner_id_id_idx ON tasks (owner_id, id);
  INSERT INTO tasks (owner_id, title) SELECT 'u-' || (g % 50), 'Task ' || g FROM generate_series(1, 20000) g;
`);
let queries = 0;
async function listTasks(ownerId: string, after = 0, limit = 50) {
  queries += 1;
  const { rows } = await db.query<{ id: number; title: string }>(
    "SELECT id, title FROM tasks WHERE owner_id = $1 AND id > $2 ORDER BY id LIMIT $3", [ownerId, after, limit]);
  return { items: rows, next: rows.length === limit ? rows.at(-1)!.id : undefined };
}
async function rowsRead(sql: string, params: unknown[]): Promise<number> {
  const { rows } = await db.query<{ "QUERY PLAN": string }>(`EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) ${sql}`, params);
  return Math.max(...rows.map((row) => Number(row["QUERY PLAN"].match(/actual rows=([\d.]+)/)?.[1] ?? 0)));
}
function check(name: string, ok: boolean): void {
  console.log(`${ok ? "PASS" : "FAIL"}  ${name}`);
}

queries = 0;
const first = await listTasks("u-7");
const deep = await listTasks("u-7", 19_000);
const body = new TextEncoder().encode(JSON.stringify(first)).length;
check("one query per page", queries === 2);
check("a page is at most 8 KB", body <= 8_192);
check("a deep page reads no more rows than it returns",
  (await rowsRead("SELECT id, title FROM tasks WHERE owner_id = $1 AND id > $2 ORDER BY id LIMIT 50", ["u-7", 19_000])) <= 50);
check("the last page has no next cursor", deep.next === undefined);
await db.close();
Output of npx tsx performance-budget.ts
PASS  one query per page
PASS  a page is at most 8 KB
PASS  a deep page reads no more rows than it returns
PASS  the last page has no next cursor

Each budget fails for a real reason: someone added a query per item, removed the limit, or wrote an OFFSET or a query the index cannot serve. Run them with the unit tests on every change (the CI/CD lesson puts them in the pipeline). Keep real load tests for a staging environment with production-like data and settings, run before big launches and compared with the previous run, not with a fixed number.

In production

Most diagnosis starts from a dashboard, so the numbers must already be there when the problem starts. For the Task API, with @zudojs/observability:

MeasureWhyAlert when
Latency p50, p95, p99 per routeWhat users feel; the tail shows problems firstp95 above the target for 5 minutes
Event-loop delay p99CPU work blocking every requestAbove 100 ms for a few minutes
CPU and heap after GC per processSaturation; leaksCPU above 80% sustained; heap growing for hours
Pool waiting count and acquire timeQueueing for connectionsAnyone waiting for more than a moment
Queries per request, slow-query logN+1 and missing indexesA route's query count jumps after a release
Cache hit rate and hot keysWhether the cache earns its keepHit rate drops sharply
Queue depth and oldest job ageWorkers falling behindOldest job older than its deadline

When an alert fires: check whether traffic changed, look at event-loop delay and CPU to tell CPU from I/O, check the dependency dashboards, then profile the one process that is slow (--cpu-prof on a single instance, or a continuous profiler if you run one). Change one thing, and compare the same numbers before and after.

Practice

TRY IT YOURSELF

Fix an N+1 without a join

The board sometimes needs assignees from a different service's table, so a join is not possible. Rewrite boardOneByOne from the database section so it makes exactly 2 queries: one for the tasks, and one for all their assignees using WHERE id = ANY($1).

Show a solution
any-query.tsNode.js only
import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
await db.exec(`
  CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);
  CREATE TABLE tasks (id SERIAL PRIMARY KEY, owner_id TEXT NOT NULL, assignee_id TEXT NOT NULL, title TEXT NOT NULL);
  INSERT INTO users SELECT 'u-' || g, 'User ' || g FROM generate_series(1, 200) g;
  INSERT INTO tasks (owner_id, assignee_id, title) SELECT 'u-1', 'u-' || (1 + g % 200), 'Task ' || g FROM generate_series(1, 5000) g;
`);
let queries = 0;
function query<T>(sql: string, params: unknown[] = []) {
  queries += 1;
  return db.query<T>(sql, params);
}

async function boardTwoQueries(ownerId: string) {
  const tasks = await query<{ id: number; title: string; assignee_id: string }>(
    "SELECT id, title, assignee_id FROM tasks WHERE owner_id = $1 ORDER BY id LIMIT 50", [ownerId]);
  const ids = [...new Set(tasks.rows.map((task) => task.assignee_id))];
  const users = await query<{ id: string; name: string }>("SELECT id, name FROM users WHERE id = ANY($1)", [ids]);
  const names = new Map(users.rows.map((user) => [user.id, user.name]));
  return tasks.rows.map((task) => ({ id: task.id, title: task.title, assignee: names.get(task.assignee_id) }));
}

const board = await boardTwoQueries("u-1");
console.log(board.length, "tasks,", queries, "queries; first:", board[0]);
await db.close();
Output of npx tsx any-query.ts
50 tasks, 2 queries; first: { id: 1, title: 'Task 1', assignee: 'User 2' }

The second query takes the whole list of ids as one array parameter. Deduplicating the ids first keeps the parameter small when many tasks share an assignee. The same pattern works for another service's API: one batch call instead of one call per row.

TRY IT YOURSELF

Top keys from an access log

Your shared cache has no hot-key statistics, but you have an access log: one key per line. Write topKeys(lines, n) that returns the n most frequent keys with their share of all reads in whole percent, most frequent first, and ties in alphabetical order.

Show a solution
top-keys.js
function topKeys(lines, n) {
  const counts = new Map();
  for (const key of lines) counts.set(key, (counts.get(key) ?? 0) + 1);
  return [...counts]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .slice(0, n)
    .map(([key, count]) => `${key} ${Math.round((count / lines.length) * 100)}%`);
}

const log = [];
for (let i = 0; i < 1_000; i++) log.push(i % 4 === 0 ? `tasks.u-${i % 7}` : i % 10 < 6 ? "board.launch-week" : "board.support");
console.log(topKeys(log, 3));
Output of node top-keys.js and of the browser terminal
[ 'board.launch-week 45%', 'board.support 30%', 'tasks.u-0 4%' ]

Counting is one pass; sorting all distinct keys is fine for a log sample. For a live stream with millions of distinct keys, keep counts only for a bounded set of candidates (a "heavy hitters" sketch), which is what getHotKeys does with its bounded tracking set.

TRY IT YOURSELF

Which bottleneck?

For each report, name the kind of slowness (CPU, I/O, or queueing) and the first thing you would measure: (a) all routes are slow at 14:00 every day, event-loop delay p99 is 800 ms, CPU is at 100% on one core; a nightly report export was moved to 14:00. (b) only POST /tasks is slow; event-loop delay is 2 ms; the database CPU is at 10%; the pool's waiting count is 30. (c) GET /tasks/:id p95 doubled after a release; the query count per request went from 1 to 3.

Show a solution
  • (a) CPU. The export runs on the same event loop as the API. Profile one process during the export to confirm; then move the export to a worker (a queue job or a worker thread, see worker threads) or stream it in chunks.
  • (b) Queueing. The database is idle and the loop is idle, but 30 requests wait for a connection: something holds connections too long. Measure how long each connection is held in POST /tasks; the usual culprit is a transaction left open while calling another service. Shorten the transaction before you touch the pool size.
  • (c) I/O, from extra queries. Compare the release's diff for new queries in that path (a new relation loaded per item, a permission check that queries) and restore the budget of one query, then add the budget test so it cannot come back unnoticed.

Summary

  • Diagnose, don't guess: measure, locate, change one thing, measure again, and compare the same numbers each time.
  • A request's time is CPU on the event loop, waiting for I/O, or waiting in a queue for a limited resource. Event-loop delay, and whether /health slows too, tell CPU apart from the rest.
  • Load-test from a separate process with autocannon, check the status counts first, and profile with node --cpu-prof. The Task API's slow list was JSON serialization of 10,000 tasks; pagination made it about 25 times faster.
  • Make load tests look like production. The rate limiter's cost appeared only because one client sent everything under a limit of a million.
  • In the database, count queries per request (N+1) and compare rows read with rows returned in EXPLAIN ANALYZE; use composite indexes and keyset pagination.
  • Pools and worker concurrency stop helping at the limit of what is behind them; Little's law gives the size, and a bigger pool only moves the queue.
  • Measure caches (hit rate, hot keys), add a local layer and jitter for hot keys, bound every in-memory structure, and write budgets on counts and bytes, not milliseconds.

Next: deploying the Task API with Docker, PostgreSQL and HTTPS, and after that the pipeline that tests and ships every change.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.