Lesson 1 closed on an unanswered question: what is $\mu$ β€” the maximum service rate of a server β€” in practice? That number cannot be read from documentation and cannot be deduced from the machine's specification. There is only one way: measure it.

This lesson stands up the measurement environment shared by all 18 lessons, then answers three questions with real figures: which queues a request passes through inside Node, how much damage one badly written handler does, and where your server saturates.

ℹ️ The machine every figure in this lesson was measured on
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Containers run native arm64 (Node v22.23.2), the app is limited to 1 CPU / 256 MB, and the load generator gets 2 CPUs.

Your numbers will differ β€” different machine, different kernel. What must be the same is the shape of the curves and the ratios between the numbers. If the shape is markedly different, the measurement is almost certainly at fault, and section 2.4 is about exactly that.

2.1 The life of an HTTP request in plain Node http

This lab uses Node's http module: no Express, no dependencies. The reason is not minimalism for its own sake β€” a framework hides precisely the places where the queues are, and queues are the main character of this whole series.

Before your JavaScript is called at all, a request has already passed through several layers β€” and each layer has a queue of its own:

The path of one request β€” and the queues hidden along it Client TCP SYN KERNEL accept queue limit: backlog full β†’ connection refused QUEUE 1 libuv epoll / kqueue reads the socket collects them into callbacks awaiting execution QUEUE 2 EVENT LOOP 1 turn ONE JS THREAD ONLY the real bottleneck JS handler your code await I/O β†’ yields busy loop β†’ holds decides everything The key point: the first two queues are managed by the OS and by libuv, and you barely ever see them. But both can only drain as fast as the single JS thread consumes callbacks. So the ΞΌ of Lesson 1 is exactly this: how many callbacks one JS thread gets through per second.
Node does have multiple threads β€” but at the I/O layer (the libuv thread pool), not at the layer that runs your JavaScript.
ℹ️ What libuv is, and why it appears in this diagram
If you have only ever written Express, you may not have met this name. libuv is the C library underneath Node, and it does one job: ask the operating system "which sockets are ready?" and turn the answer into JavaScript callbacks queued up to run. It uses whichever mechanism the OS provides to ask β€” epoll on Linux, kqueue on macOS β€” and those are the two words you see in the middle box of the diagram.

Why it matters: libuv is where thousands of connections wait at once without costing thousands of threads. It also keeps its own thread pool for a few heavy jobs (file reads, crypto, DNS) β€” that is where Node genuinely runs in parallel. But that thread pool does not run your JavaScript: every callback still queues up for the one and only JS thread, and that is the bottleneck section 2.2 is about.
πŸ•³οΈ Pitfall: assuming Node runs your JavaScript in parallel
Node is marketed as "non-blocking, handles thousands of concurrent connections", which makes it very easy to conclude that it runs code in parallel. It does not. There is exactly one thread running JavaScript. The parallelism is in the waiting for I/O: 10,000 connections can wait at once, but only one callback executes at any moment.

The direct consequence: if your handler occupies that thread, the entire server stops β€” not just that request. Section 2.2 measures the damage precisely.

2.2 The event loop and the blocking culprit

The lab's app has two endpoints that are equally slow in wall-clock terms but fundamentally different in nature β€” and that difference is the whole problem:

app/app.js β€” two completely different kinds of "slow"
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/** Block the event loop for `ms` β€” simulates a heavy synchronous handler. */
function blockFor(ms) {
  const until = Date.now() + ms;
  // Busy loop: does NOT yield to the event loop, so every other request queues up.
  while (Date.now() < until) {
    /* burning CPU on purpose */
  }
}

// ...inside the request handler:

    if (p === '/slow-async') {
      const ms = Number(url.searchParams.get('ms') || 50);
      await sleep(ms); // yields the event loop => other requests still get served
      return json(res, 200, { ok: true, mode: 'async', ms, instance: INSTANCE });
    }

    if (p === '/slow-sync') {
      const ms = Number(url.searchParams.get('ms') || 50);
      blockFor(ms); // BLOCKS the event loop => every other request waits
      return json(res, 200, { ok: true, mode: 'sync-blocking', ms, instance: INSTANCE });
    }

Called one at a time, these two endpoints return after the same interval. The difference only appears when other requests are in flight at the same time:

The same 20 ms of work β€” two different outcomes await sleep(20) β€” yields the event loop JS thread time β†’ The slow handler only occupies the thread at each end (amber); during the 20 ms wait it is off the thread, so 44 /fast requests (blue) are still served in between β€” measured: p99 of just 0.71 ms. blockFor(20) β€” holds the event loop JS thread The JS thread is occupied continuously. A /fast request (blue) only squeezes into the gap between two blocks, so it waits its turn: p99 jumps to 160 ms and throughput collapses to about 1/230. The same amount of work, the same CPU. The only difference: one YIELDS the thread while waiting, the other HOLDS it. That is the entire distance between 29,000 req/s and 129 req/s.
Amber/red is the JS thread running the slow handler, blue is a /fast request. The figure depicts exactly the phenomenon measured in the table below.

The real measurements

Background load is applied to /slow-sync?ms=20 with 4 connections while /fast is measured with 10 connections. Repeated three times, with nothing changed in between:

Run /fast when idle /fast with a blocking handler running in the background Collapse factor
1 29,647 req/s 129 req/s 229Γ—
2 28,922 req/s 116 req/s 250Γ—
3 29,521 req/s 137 req/s 216Γ—

A 20 ms synchronous handler collapses a different endpoint's throughput by roughly 200–250Γ—, and takes its p99 from 0.71 ms to about 160 ms. Not one line of /fast was changed β€” its only crime is sharing a process.

πŸ•³οΈ Pitfall: *Sync functions inside a request handler
In real code, blockFor() rarely appears as an obvious busy loop like the one above. It arrives in lines that look entirely harmless:

crypto.pbkdf2Sync(...) to hash a password Β· fs.readFileSync(...) reading a config file "just once" Β· JSON.parse() on a payload of a few MB Β· zlib.gzipSync(...) Β· a loop processing an array of 100,000 elements.

The common wrong instinct: "if it is slow, only that request is slow". The table above shows the consequence landing on every other request. And because you usually only measure the endpoint you just wrote, the damage shows up somewhere else β€” which makes it extremely hard to trace.

2.3 Standing up the lab shared by the whole series

The entire "run it" track across 18 lessons uses one Docker stack, extended gradually through profiles. You build it once here and then only switch on an extra profile when a later lesson needs it.

docker-compose.yml (core excerpt)
name: sysdesign-lab

# Shared config for every app server β€” a YAML anchor, so it is not repeated 3 times.
x-app-base: &app-base
  image: node:22-alpine
  working_dir: /app
  volumes:
    - ./app:/app:ro          # bind-mount: KHONG can Dockerfile, khong can npm install
  command: ['node', 'app.js']
  deploy:
    resources:
      limits:
        cpus: '1.0'          # CO CHU Y: xem phan cuoi bai
        memory: 256M

services:
  app1:
    <<: *app-base
    profiles: ['base', 'lb', 'cache', 'db']
    environment:
      INSTANCE: 'app1'
      DB_DELAY_MS: '40'
      GRACEFUL: '1'
    ports:
      - '3001:3000'          # chi app1 mo cong ra host, de bai nay goi truc tiep

  # ... app2, app3, lb, redis, postgres β€” enabled per profile ...

  loadgen:
    image: node:22-alpine
    profiles: ['tools']      # not started automatically; invoked by hand to measure
    working_dir: /loadgen
    volumes:
      - ./loadgen:/loadgen:ro
    entrypoint: ['node']
    deploy:
      resources:
        limits:
          cpus: '2.0'        # more than the app, so the tool is not the bottleneck
ℹ️ From here on you need Docker
The previous four sections read fine with nothing installed. From this section you need Docker Desktop (macOS/Windows) or Docker Engine with Docker Compose v2 (Linux) β€” get it from docs.docker.com/get-started/get-docker. Check with docker compose version; it must report v2 or later, because docker-compose v1 (with the hyphen) does not understand profiles.

Without Docker you can still read the whole lesson and still compare against the Traffic Lab at the end β€” you only lose the part where you measure your own machine.

Bring it up and check the app is alive:

terminal
cd blog/sysdesign/sysdesign-lab
docker compose --profile base up -d

curl -s http://localhost:3001/health
# {"status":"ok","instance":"app1","uptimeSec":1,"inFlight":1,"totalRequests":2}
πŸ•³οΈ The first and most expensive pitfall: an image for the wrong CPU architecture
This actually happened while building this lab. The machine is Apple Silicon (arm64) but the node:22-alpine image already cached locally was linux/amd64 β€” so the container ran through an emulation layer. Docker prints one faint warning line among dozens of log lines and then carries on normally.

The consequence: many times slower, heavily noisy latency, and every subsequent measurement meaningless β€” with nothing raising an error. So run a preflight check, and run it from inside the container:

uname -m β†’ compare with β†’ docker compose exec app1 node -e "console.log(process.arch)"

The two must agree (arm64 ↔ arm64, x86_64 ↔ amd64). If they differ: docker pull --platform linux/arm64 node:22-alpine.

2.4 Measuring correctly

This section is why the lab uses a hand-written load generator (loadgen/loadgen.js) rather than wrk. Not because wrk is worse β€” it is considerably better β€” but because if the measuring tool is a black box, the lesson about how to measure disappears with it.

Four principles, and what ignoring them costs

1. Discard the warm-up. For the first few seconds the JIT has not optimised the code, caches are cold, and connections are not all open. Figures from this phase do not represent steady state. The lab's tool discards every sample collected before the warm-up mark, rather than averaging over the whole run.

2. Understand that the connection count is $L$. The tool runs closed-loop: each connection only sends its next request after receiving a response. So the connection count = the maximum number of concurrent requests = exactly the quantity $L$ in Little's Law from Lesson 1. This is not a trivial technicality β€” section 2.5 uses that very relationship to confirm the formula against measurements.

3. Do not let the tool and the server fight over the same CPU. If both compete for cores, you do not know whether you are measuring the server's limit or the tool's. In the lab the app is limited to 1 CPU and the tool gets 2, and we always verify with docker stats:

docker stats β€” with /fast saturated (32 connections)
$ docker stats --no-stream --format '{{.Name}}  CPU={{.CPUPerc}}  MEM={{.MemUsage}}'

cpu-probe             CPU=101.20%  MEM=50.55MiB / 7.653GiB   # bo do: 101% tren HAN MUC 200%
sysdesign-lab-app1-1  CPU=82.72%   MEM=81.96MiB / 256MiB     # app:   83% tren han muc 100%

Read those two lines: the app is at 83% of the 1 CPU it was given β€” close to saturation. The tool is using 101% out of a 200% allowance, so roughly half its capacity. That lets us conclude that the throughput ceiling we measured is the server's ceiling, not the tool's. If the tool had hit 100% of its own allowance, every number would have to be thrown away.

4. One run is not a measurement. The three-repeat table in section 2.2 shows the same experiment, unchanged, producing collapse factors of 229Γ— / 250Γ— / 216Γ—. Reporting "collapsed 250Γ—" from a single run is the number of one run, not of the system. The honest form is a range: 200–250Γ—.

πŸ”¬ Going deeper: coordinated omission β€” why closed-loop measurement flatters p99
Lesson 1 named this phenomenon; now we can see it sitting inside our own tool. Because each connection has to wait for a response before sending again, the tool automatically sends less when the server slows down. Precisely the requests that should have landed in the worst period are never generated at all.

Real users are not so polite: they keep clicking, keep hitting F5, and keep arriving while the server is dying. That is open-loop measurement, and it always produces a worse p99.

So every p99 in this series should be read as a lower bound on how bad it gets β€” production will be worse. It is also why loadgen.js states its three limitations in the comment block at the top of the file, rather than leaving the reader to guess.

2.5 Finding the knee point: where does your server saturate?

Now to answer Lesson 1's question. We raise the connection count step by step and record throughput alongside latency. The command for each data point:

load sweep
for c in 1 2 4 8 16 32 64 128; do
  docker compose run --rm loadgen loadgen.js \
    --url http://app1:3000/fast -c $c -d 6 -w 2 --json
done

Real results on the machine listed at the top of the lesson:

The knee point of /fast β€” real measurements (Apple M1 Max, app limited to 1 CPU) knee β‰ˆ 4 connections 1 2 4 8 16 32 64 128 concurrent connections (L) throughput (peak 30,577 req/s) p99 latency (0.15 ms β†’ 8.29 ms) Before the knee more connections = more throughput, for free After the knee: throughput stands still, latency doubles every time connections double From 64 to 128 connections throughput actually FALLS (29,694 β†’ 28,967) β€” the cost of managing concurrency. The two axes have their own scales; where they meet at the right edge is an artefact of the normalisation.
All 8 points on each line are real measurements, not interpolated.
Connections Throughput (req/s) p50 (ms) p99 (ms) Reading
1 8,344 0.12 0.15 Not yet using full capacity
2 14,118 0.14 0.20 Nearly double β€” still headroom
4 29,604 0.13 0.27 The knee β€” throughput hits the ceiling, latency has not risen
8 30,531 0.22 0.48 Throughput flat, latency starting to climb
16 30,577 0.47 0.99 Peak throughput; p99 already 6.6Γ— higher
32 30,278 0.99 2.03 Now only paying in latency
64 29,694 2.06 4.14 Throughput starting to fall
128 28,967 4.27 8.29 Worse in every respect than 16 connections

Reading this table is the whole value of the lesson. Going from 16 to 128 connections, throughput drops 5% while p99 rises 8.4Γ—. Past the knee, every additional concurrent request is pure cost β€” it only makes the queue longer. This is Lesson 1's non-linear wall, this time in measurements from a real machine.

Confirming Little's Law experimentally

The most convincing measurement comes from a different endpoint. Using /slow-sync?ms=5 β€” which occupies exactly 5 ms of CPU per request β€” the maximum service rate on one CPU must be $\mu = 1000/5 = 200$ req/s. Measured:

Connections (L) Measured throughput Measured mean latency (W) $L / \lambda$ from Little's Law
1 198.8 req/s 5.0 ms 5.0 ms
2 197.3 req/s 10.1 ms 10.0 ms
8 200.0 req/s 40.0 ms 40.0 ms
16 199.6 req/s 80.1 ms 80.0 ms
32 200.0 req/s 160.1 ms 160.0 ms
64 199.7 req/s 320.5 ms 320.0 ms

The last column is computed from $W = L/\lambda$ with $\lambda = 200$. The discrepancy against the measurements is under 0.3% at every level. Little's Law is not decorative theory β€” it is an equality that holds figure by figure on a real machine.

And note the most surprising thing in that table: throughput is flat at 200 req/s from a single connection onwards. With a CPU-bound handler on a single-threaded server, the server is already saturated at exactly one concurrent request. Every additional connection buys nothing at all β€” it only lengthens the queue and raises latency linearly.

πŸ•³οΈ Pitfall: concluding "the server handles X req/s" from one measurement
That sentence is missing the two most important pieces of information: at how many concurrent connections, and at what p99. Look at the table above: saying "/fast handles 29,000 req/s" is true both at 4 connections (p99 = 0.27 ms) and at 128 connections (p99 = 8.29 ms) β€” two completely different systems from the user's point of view.

A complete statement has to take the form: "29,600 req/s at 4 concurrent connections with p99 = 0.27 ms, measured on [machine configuration]".
πŸ’‘ Do it yourself: find your own machine's knee point
Run exactly the sweep loop above and build the same table. Three questions to answer for yourself:

1. Where is your knee, in connections? (More cores does not necessarily mean higher β€” the app is limited to 1 CPU.)
2. Switch to /slow-sync?ms=5: is throughput flat at around 200 req/s? If it is far off, re-check the CPU-architecture preflight in section 2.3.
3. Drop -w 2 (no warm-up) and compare: how far apart are the numbers?

Comparing against the simulation

The Traffic Lab below simulates exactly the situation we just measured. Pick the "1. A single server" topology and drag the load up: you will see the same shape as the table β€” throughput hits its ceiling and stops, while the queue and p99 keep climbing.

The limit is known β€” now what?

We have $\mu$ as a measurement, we know where the knee is, and we know a handler that blocks the event loop costs 200–250Γ—. But however perfectly the code is written, one server still has a hard ceiling: one CPU, one process, one point of failure.

Lesson 3 puts nginx in front and multiplies the app into 3 replicas, then measures these very same numbers again. Along the way we meet two counter-intuitive results: a load-balancing algorithm whose "requests per node" chart looks perfectly balanced while its p99 is nearly twice as bad as the alternative, and the absence of graceful shutdown turning every deploy into a countable burst of 502s for real users.

πŸ“– Further reading

Download the lab source

The app server written on the plain http module (0 dependencies, including a minimal Redis client that speaks the RESP protocol directly) and the hand-written load generator, whose three limitations are stated in the file:

Download app.js Download loadgen.js

Related lessons in this series

Lesson 1: Latency, Throughput & Queueing Theory Lesson 3: Scaling Out & Load Balancing Back to the System Design roadmap

Comments