AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

15 Performance and memory / Lesson 5 of 5

Server and network performance — latency budgets, percentiles, caching and the event loop under load

13 min · +10 XP · 2 exercises · 5 questions

Read the text13 min
Exercises0/2
Quiz5 q

Quick answer

How do you make a Node.js API faster?

Count the sequential round trips first: run independent queries together with Promise.all, replace N+1 loops with one IN query or a join, and fetch only the columns you need. Then cache composed payloads in bounded, namespaced caches, keep the event loop free of synchronous CPU work, compress and paginate responses, and track p95 and p99 latency per route.

A Node service is slow for reasons that rarely appear in a CPU profile: it waits — on a database 500 ms away, on ten sequential awaits that could be one, on a connection pool that is full, on a cold cache. The unit of performance here is latency at the percentile users feel, not CPU time, and the levers are round trips, caching, concurrency, and keeping the event loop free. This lesson covers how to think in latency budgets, why p99 matters more than the average, the round-trip arithmetic behind "count your awaits", caching layers and their invalidation, pools and backpressure, event-loop lag as the health metric of a Node process, and how to load-test before users do.

Latency budgets and percentiles

Decide what "fast" means as a number with a percentile: p95 of /api/dashboard under 300 ms. Percentiles: p50 is the typical request, p95 the slow-but-common, p99 the tail. Averages are useless — a mean of 120 ms can hide 5% of requests at 3 s, which is 5% of users refreshing. Tails compound: a page making 10 parallel calls sees the max of 10 draws, so its p50 is roughly the calls' p93. Budget the tail: if the target is 300 ms and the database round trip is 50 ms, sequential calls are capped at five before there is nothing left for the rest.

Round trips: count the awaits

const user = await db.user(id);              // 50 ms to the database
const team = await db.team(user.teamId);     // another 50 — dependent, unavoidable
const [posts, badges] = await Promise.all([db.posts(id), db.badges(id)]);   // independent: one round trip, not two

In a topology where the database is far away (an API in Singapore querying a database in Phoenix, say, pays a couple of hundred milliseconds a round trip), the number of sequential awaits is the response time. Levers: run independent queries concurrently (Promise.all); replace N queries with one (WHERE id IN (...), a join, a batch endpoint — the N+1 problem is a loop of awaits); fetch only needed columns; cache composed payloads; move the compute next to the data (co-location is the biggest lever of all). The same arithmetic applies to a browser calling an API: waterfalls of dependent fetches are the classic slow page.

Caching layers

A cache turns a slow, repeated read into a fast one at the price of staleness and invalidation:

  • In-process (L1): a bounded Map/LRU with a TTL — microseconds, per instance, lost on restart, inconsistent across instances.
  • Shared (L2): Redis/Memcached — a network hop (often 1–300 ms depending on where it is), shared by all instances.
  • HTTP: Cache-Control, ETag/If-None-Match, CDNs — the fastest cache is the one in the browser or at the edge that never reaches you.

Patterns: cache-aside (check, miss → load → store), stale-while-revalidate (serve stale immediately, refresh in the background — module 12's checkpoint), single-flight (one loader per key at a time so a cache miss under load does not stampede the database — cache the promise, module 8), write-through/invalidate-on-write (delete or update the key when the data changes; broadcast the invalidation to other instances). Cache composed payloads (the dashboard object) rather than individual rows: one hit saves ten queries, and invalidation has one key. Namespace keys by environment/database so a local process pointed at a shared Redis cannot feed production its data — this repository learned that one the hard way.

Pools and backpressure

A database allows N connections; a pool hands them out and queues callers beyond that. A slow query holds a connection; under load the queue grows and every request waits — latency climbs before CPU does. Size pools to the database's limit divided by your instance count, keep queries short, and add timeouts so a stuck connection is reclaimed. Backpressure is the same idea for streams (module 9) and for producers of any kind: when the consumer cannot keep up, slow the producer (bounded queues, write() returning false, Promise pools with a limit — module 8) rather than buffering without bound until memory runs out. Rate limiting (this repository's middleware/rate-limit.ts) is backpressure at the edge.

The event loop under load

Node handles thousands of concurrent connections with one thread only while that thread is free. Any synchronous work — JSON parsing a 5 MB body, a regex with catastrophic backtracking (module 10), synchronous fs, a tight loop — blocks every other request for its duration. Event-loop lag (how late timers fire: perf_hooks.monitorEventLoopDelay(), or a setInterval that measures its own drift) is the single best health metric of a Node process: p99 lag over ~50–100 ms means requests are queuing behind CPU work. Fixes: move CPU work to workers, stream large bodies, cap body sizes (express.json({ limit })), and never readFileSync in a request handler. cluster/multiple processes use the other cores; each is still one loop.

Payloads and the wire

Compress responses (compression middleware, Brotli/gzip — text shrinks 5–10×); send only needed fields; paginate lists (cursor-based for large tables); stream large responses; keep connections alive (HTTP keep-alive / HTTP/2 multiplexing); set cache headers; put static assets on a CDN with immutable hashed names (module 14). On the browser side, the order of requests (critical first), preloading, and avoiding request waterfalls decide the perceived speed more than any server optimisation.

Load testing

Before users do: autocannon/k6/wrk against a staging environment with production-like data, ramping concurrency while watching p50/p95/p99, error rate, event-loop lag, pool queue length and memory. Find the knee — the concurrency where latency starts climbing — and the failure mode past it (timeouts? memory? pool exhaustion?). Then fix the first bottleneck and repeat; the knee moves. A load test that only reports requests-per-second at a fixed concurrency tells you little.

Observability

Record per-request duration, route, status and the time spent in each dependency (database, cache, external API); export histograms; alert on p99 and error rate per route; correlate deploys with regressions. Structured logs with a request id let you follow one slow request across services. This is the production half of "measure first" — laboratory profiles find CPU hot spots; only production telemetry finds the slow database, the cold cache and the queueing pool.

Common mistakes

  • Sequential awaits for independent work; N+1 queries; fetching whole rows and whole tables.
  • Unbounded or un-namespaced caches; cache stampedes on miss; caching rows instead of payloads.
  • Synchronous CPU work or fs in request handlers; huge bodies parsed in memory.
  • Pool sizes larger than the database allows (times the instance count); no query timeouts.
  • Reporting averages; load testing without percentiles or lag; optimising CPU when the profile is 95% waiting.

Interview angle

  • "Why p99 and not the average?" Tails are where users suffer and where fan-out compounds; averages hide both the typical case and the tail.
  • "What is the N+1 problem?" A query per item in a loop — n round trips instead of one batch query; fix with IN/joins or a batch endpoint.
  • "How do you keep a Node server responsive?" Keep the loop free: workers for CPU, streams for large data, body limits, no sync I/O; watch event-loop lag.
  • "Cache-aside versus stale-while-revalidate?" Miss → load → store versus serve stale now and refresh in the background; single-flight prevents stampedes.
  • "What is backpressure on a server?" Bounded queues and pools that slow producers when consumers cannot keep up, instead of buffering until memory runs out.

Key takeaways

  • Set latency budgets at percentiles; tails compound under fan-out; averages hide everything.
  • Response time ≈ sequential round trips: Promise.all independent work, batch N+1, cache composed payloads, co-locate.
  • Caches: L1 bounded + L2 shared + HTTP; cache-aside, SWR, single-flight; invalidate on write; namespace keys.
  • Pools and bounded queues are backpressure; event-loop lag is the health metric; CPU work leaves the loop.
  • Compress, paginate, stream, keep-alive; load-test to the knee with percentiles; observe per route in production.

Common questions

Why measure p99 latency instead of the average?

Averages hide the tail: a mean of 120 ms can conceal 5% of requests taking three seconds. Tails also compound — a page making ten parallel calls waits for the slowest, so its median sits near the calls' p93 — which is why budgets are set at p95 or p99.

What is the N+1 query problem?

Running one query to fetch a list and then one more query per item, so n items cost n + 1 round trips instead of one or two. Fix it with a single WHERE id IN (...) query, a join or a batch endpoint; with a distant database, that loop of awaits is the response time.

What is event loop lag in Node.js?

How late timers fire compared with when they were scheduled — a direct measure of how long requests wait behind synchronous work on Node's single thread. Measure it with perf_hooks.monitorEventLoopDelay(); p99 lag above roughly 50–100 ms means requests are queuing behind CPU work.

What is the difference between cache-aside and stale-while-revalidate?

Cache-aside checks the cache and, on a miss, loads the data, stores it and returns it. Stale-while-revalidate serves the stale value immediately and refreshes it in the background. Either way, single-flight loading — one loader per key at a time — stops a miss under load from stampeding the database.

What is backpressure in Node.js?

Slowing a producer when its consumer cannot keep up, instead of buffering without limit until memory runs out. Streams signal it when write() returns false; bounded queues, promise pools with a limit, connection pools and rate limiting apply the same idea to servers.

Mark as readSign in to keep your place.

Node.js Performance: p99 Latency, N+1 Queries and Caching

Slow Node.js APIs are usually waiting. Latency budgets at p95 and p99, counting awaits, N+1 queries, caching layers, pools, event loop lag and load testing.

  • Course: JavaScript study plan
  • Module: Performance and memory
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: Node 16

How do you make a Node.js API faster?

Count the sequential round trips first: run independent queries together with Promise.all, replace N+1 loops with one IN query or a join, and fetch only the columns you need. Then cache composed payloads in bounded, namespaced caches, keep the event loop free of synchronous CPU work, compress and paginate responses, and track p95 and p99 latency per route.

Lesson

A Node service is slow for reasons that rarely appear in a CPU profile: it waits — on a database 500 ms away, on ten sequential awaits that could be one, on a connection pool that is full, on a cold cache. The unit of performance here is latency at the percentile users feel, not CPU time, and the levers are round trips, caching, concurrency, and keeping the event loop free. This lesson covers how to think in latency budgets, why p99 matters more than the average, the round-trip arithmetic behind "count your awaits", caching layers and their invalidation, pools and backpressure, event-loop lag as the health metric of a Node process, and how to load-test before users do.

Latency budgets and percentiles

Decide what "fast" means as a number with a percentile: p95 of /api/dashboard under 300 ms. Percentiles: p50 is the typical request, p95 the slow-but-common, p99 the tail. Averages are useless — a mean of 120 ms can hide 5% of requests at 3 s, which is 5% of users refreshing. Tails compound: a page making 10 parallel calls sees the max of 10 draws, so its p50 is roughly the calls' p93. Budget the tail: if the target is 300 ms and the database round trip is 50 ms, sequential calls are capped at five before there is nothing left for the rest.

Round trips: count the awaits

const user = await db.user(id);              // 50 ms to the database
const team = await db.team(user.teamId);     // another 50 — dependent, unavoidable
const [posts, badges] = await Promise.all([db.posts(id), db.badges(id)]);   // independent: one round trip, not two

In a topology where the database is far away (an API in Singapore querying a database in Phoenix, say, pays a couple of hundred milliseconds a round trip), the number of sequential awaits is the response time. Levers: run independent queries concurrently (Promise.all); replace N queries with one (WHERE id IN (...), a join, a batch endpoint — the N+1 problem is a loop of awaits); fetch only needed columns; cache composed payloads; move the compute next to the data (co-location is the biggest lever of all). The same arithmetic applies to a browser calling an API: waterfalls of dependent fetches are the classic slow page.

Caching layers

A cache turns a slow, repeated read into a fast one at the price of staleness and invalidation:

  • In-process (L1): a bounded Map/LRU with a TTL — microseconds, per instance, lost on restart, inconsistent across instances.
  • Shared (L2): Redis/Memcached — a network hop (often 1–300 ms depending on where it is), shared by all instances.
  • HTTP: Cache-Control, ETag/If-None-Match, CDNs — the fastest cache is the one in the browser or at the edge that never reaches you.

Patterns: cache-aside (check, miss → load → store), stale-while-revalidate (serve stale immediately, refresh in the background — module 12's checkpoint), single-flight (one loader per key at a time so a cache miss under load does not stampede the database — cache the promise, module 8), write-through/invalidate-on-write (delete or update the key when the data changes; broadcast the invalidation to other instances). Cache composed payloads (the dashboard object) rather than individual rows: one hit saves ten queries, and invalidation has one key. Namespace keys by environment/database so a local process pointed at a shared Redis cannot feed production its data — this repository learned that one the hard way.

Pools and backpressure

A database allows N connections; a pool hands them out and queues callers beyond that. A slow query holds a connection; under load the queue grows and every request waits — latency climbs before CPU does. Size pools to the database's limit divided by your instance count, keep queries short, and add timeouts so a stuck connection is reclaimed. Backpressure is the same idea for streams (module 9) and for producers of any kind: when the consumer cannot keep up, slow the producer (bounded queues, write() returning false, Promise pools with a limit — module 8) rather than buffering without bound until memory runs out. Rate limiting (this repository's middleware/rate-limit.ts) is backpressure at the edge.

The event loop under load

Node handles thousands of concurrent connections with one thread only while that thread is free. Any synchronous work — JSON parsing a 5 MB body, a regex with catastrophic backtracking (module 10), synchronous fs, a tight loop — blocks every other request for its duration. Event-loop lag (how late timers fire: perf_hooks.monitorEventLoopDelay(), or a setInterval that measures its own drift) is the single best health metric of a Node process: p99 lag over ~50–100 ms means requests are queuing behind CPU work. Fixes: move CPU work to workers, stream large bodies, cap body sizes (express.json({ limit })), and never readFileSync in a request handler. cluster/multiple processes use the other cores; each is still one loop.

Payloads and the wire

Compress responses (compression middleware, Brotli/gzip — text shrinks 5–10×); send only needed fields; paginate lists (cursor-based for large tables); stream large responses; keep connections alive (HTTP keep-alive / HTTP/2 multiplexing); set cache headers; put static assets on a CDN with immutable hashed names (module 14). On the browser side, the order of requests (critical first), preloading, and avoiding request waterfalls decide the perceived speed more than any server optimisation.

Load testing

Before users do: autocannon/k6/wrk against a staging environment with production-like data, ramping concurrency while watching p50/p95/p99, error rate, event-loop lag, pool queue length and memory. Find the knee — the concurrency where latency starts climbing — and the failure mode past it (timeouts? memory? pool exhaustion?). Then fix the first bottleneck and repeat; the knee moves. A load test that only reports requests-per-second at a fixed concurrency tells you little.

Observability

Record per-request duration, route, status and the time spent in each dependency (database, cache, external API); export histograms; alert on p99 and error rate per route; correlate deploys with regressions. Structured logs with a request id let you follow one slow request across services. This is the production half of "measure first" — laboratory profiles find CPU hot spots; only production telemetry finds the slow database, the cold cache and the queueing pool.

Common mistakes

  • Sequential awaits for independent work; N+1 queries; fetching whole rows and whole tables.
  • Unbounded or un-namespaced caches; cache stampedes on miss; caching rows instead of payloads.
  • Synchronous CPU work or fs in request handlers; huge bodies parsed in memory.
  • Pool sizes larger than the database allows (times the instance count); no query timeouts.
  • Reporting averages; load testing without percentiles or lag; optimising CPU when the profile is 95% waiting.

Interview angle

  • "Why p99 and not the average?" Tails are where users suffer and where fan-out compounds; averages hide both the typical case and the tail.
  • "What is the N+1 problem?" A query per item in a loop — n round trips instead of one batch query; fix with IN/joins or a batch endpoint.
  • "How do you keep a Node server responsive?" Keep the loop free: workers for CPU, streams for large data, body limits, no sync I/O; watch event-loop lag.
  • "Cache-aside versus stale-while-revalidate?" Miss → load → store versus serve stale now and refresh in the background; single-flight prevents stampedes.
  • "What is backpressure on a server?" Bounded queues and pools that slow producers when consumers cannot keep up, instead of buffering until memory runs out.

Key takeaways

  • Set latency budgets at percentiles; tails compound under fan-out; averages hide everything.
  • Response time ≈ sequential round trips: Promise.all independent work, batch N+1, cache composed payloads, co-locate.
  • Caches: L1 bounded + L2 shared + HTTP; cache-aside, SWR, single-flight; invalidate on write; namespace keys.
  • Pools and bounded queues are backpressure; event-loop lag is the health metric; CPU work leaves the loop.
  • Compress, paginate, stream, keep-alive; load-test to the knee with percentiles; observe per route in production.

Common questions

Why measure p99 latency instead of the average?

Averages hide the tail: a mean of 120 ms can conceal 5% of requests taking three seconds. Tails also compound — a page making ten parallel calls waits for the slowest, so its median sits near the calls' p93 — which is why budgets are set at p95 or p99.

What is the N+1 query problem?

Running one query to fetch a list and then one more query per item, so n items cost n + 1 round trips instead of one or two. Fix it with a single WHERE id IN (...) query, a join or a batch endpoint; with a distant database, that loop of awaits is the response time.

What is event loop lag in Node.js?

How late timers fire compared with when they were scheduled — a direct measure of how long requests wait behind synchronous work on Node's single thread. Measure it with perf_hooks.monitorEventLoopDelay(); p99 lag above roughly 50–100 ms means requests are queuing behind CPU work.

What is the difference between cache-aside and stale-while-revalidate?

Cache-aside checks the cache and, on a miss, loads the data, stores it and returns it. Stale-while-revalidate serves the stale value immediately and refreshes it in the background. Either way, single-flight loading — one loader per key at a time — stops a miss under load from stampeding the database.

What is backpressure in Node.js?

Slowing a producer when its consumer cannot keep up, instead of buffering without limit until memory runs out. Streams signal it when write() returns false; bounded queues, promise pools with a limit, connection pools and rate limiting apply the same idea to servers.

Exercises

Percentiles and SLO burn

Line 1 is slo=<ms> target=<percent>; the following lines are <name>: <ms> <ms> … request durations. For each series print <name>: n=<> p50=<> p95=<> p99=<> mean=<one decimal> withinSLO=<xx.xx>% budgetBurn=<n>% <OK|BREACH> — percentiles by nearest rank, budget burn = requests over the SLO ÷ the allowed failures (n × (1 − target/100)) as a percentage. When both before and after exist, add p95 change: <±x.x>% -> <regression|improvement|no significant change> (more than ±10%) followed by ; mean change <x.x>% (means hide the tail).

Example: slo=300 target=99, a before series with one 950 ms request among twenty, an after series without it →

before: n=20 p50=127 p95=140 p99=950 mean=168.4 withinSLO=95.00% budgetBurn=500% BREACH
after: n=20 p50=125 p95=136 p99=138 mean=125.5 withinSLO=100.00% budgetBurn=0% OK
p95 change: -2.9% -> no significant change; mean change -25.5% (means hide the tail)

One tail request burned five times the error budget; the mean moved 25% while p95 barely moved.

Cache-hit simulator

Line 1 lists cache capacities; line 2 is a request trace of keys, | separating batches — keys inside a batch arrive concurrently, so a miss's load only lands after the batch. For each capacity run an LRU cache (a Map re-inserted on hit, oldest evicted) over the trace: a hit costs 2 ms, a miss 60 ms; a miss whose key is already loading in the same batch is coalesced (single-flight: it waits but starts no load). Print capacity=<c>: hits=<> misses=<> (<n> coalesced) hitRatio=<xx.x>% backendLoads=<> avgLatency=<x.x>ms per capacity, then no cache: avgLatency=60ms backendLoads=<total>.

Example: capacities 1 2 3, trace a a a | b | a | c | a | b | d | a | e | a | b →

capacity=1: hits=0 misses=13 (2 coalesced) hitRatio=0.0% backendLoads=11 avgLatency=60.0ms
capacity=2: hits=3 misses=10 (2 coalesced) hitRatio=23.1% backendLoads=8 avgLatency=46.6ms
capacity=3: hits=5 misses=8 (2 coalesced) hitRatio=38.5% backendLoads=6 avgLatency=37.7ms
no cache: avgLatency=60ms backendLoads=13

Three concurrent requests for a produced one backend load; the capacity decides everything after that.

In this module: Performance and memory

← Writing fast JavaScript — the habits that survive measurement · Checkpoint — Performance and memory →