Fastify and Express measured against a real PostgreSQL database, with event loop utilization sampled on the server so throughput can be converted into microseconds of event loop CPU per request.
The short version: Fastify is 4.37x faster on an empty handler, 1.92x on a real single-row query, and 1.14x when the response grows to 27 KB. The framework's cost never changes. What changes is how much of the request it accounts for.
Write-up: Fastify vs Express: What the 4x Actually Buys You
npm install
npm run db:up # postgres:15 on port 55432, schema + 100k rows auto-loaded
npm run bench # the main run, roughly 5 minutes
npm run bench:stopping-rule # fixed duration vs fixed request count
npm run db:downResults are written to results/*.json.
Three workloads, one variable at a time.
| Workload | Handler does | JSON body |
|---|---|---|
A. empty (/static/:id) |
returns a fixed object, no I/O | 269 B |
B. point lookup (/users/:id) |
one indexed query, one row | 269 B |
C. list (/list) |
one query, 100 rows | 27,243 B |
A and B return the same body. The only difference is whether a database round-trip happened, so A against B isolates what the driver costs and B against C isolates what payload size costs.
Three server configurations: express, fastify, and fastify-schema (Fastify
with a JSON Schema on the response, which switches on fast-json-stringify).
- One Node process per configuration, no cluster, no proxy, no TLS.
- Load generator and server on the same host over loopback.
- Connection pool fixed at 10 for every run.
- Per measurement: 3 seconds of warm-up, discarded, then 10 seconds measured.
- Concurrency swept at 10, 50, 200 and 500 rather than fixed at one value.
- The harness refuses to measure until
/__whoamiconfirms the process on the port is the framework it asked for. A stray server from an earlier run would otherwise be measured silently, and the numbers look plausible.
Event loop utilization is sampled around the measured window:
const { performance } = require("node:perf_hooks");
let mark;
app.get("/__elu/start", () => { mark = performance.eventLoopUtilization(); return { ok: true }; });
app.get("/__elu/stop", () => performance.eventLoopUtilization(mark));which gives the number that actually transfers between machines:
microseconds of event loop CPU per request = (ELU * 1_000_000) / requests_per_second
Throughput alone cannot tell you why a number stopped rising. ELU can.
Apple M4 Pro (12 cores), Node v22.13.1, Express 5.2.1, Fastify 5.11.2, pg 8.22.0, autocannon 8.0.0, PostgreSQL 15 in Docker. Run 2026-08-07. Zero non-2xx responses and zero errors in every run.
| req/s | ELU | CPU/req | vs Express | |
|---|---|---|---|---|
| Express | 23,771 | 100% | 42.1 us | 1x |
| Fastify | 103,927 | 100% | 9.6 us | 4.37x |
| Fastify + schema | 100,972 | 100% | 9.9 us | 4.25x |
| conn | Express | ELU | p50 | Fastify | ELU | p50 | ratio |
|---|---|---|---|---|---|---|---|
| 10 | 14,696 | 99.9% | 0 ms | 26,274 | 93.9% | 0 ms | 1.79x |
| 50 | 14,970 | 100% | 3 ms | 28,729 | 98.2% | 1 ms | 1.92x |
| 200 | 14,483 | 100% | 13 ms | 28,053 | 98.6% | 7 ms | 1.94x |
| 500 | 13,944 | 100% | 35 ms | 27,752 | 98.8% | 17 ms | 1.99x |
Express is event-loop bound from 10 connections onward. Going to 500 adds no throughput at all; it adds 35 ms of queueing to p50. Once ELU is pinned at 100%, concurrency converts directly into latency.
| req/s | CPU/req | vs Express | |
|---|---|---|---|
| Express | 2,612 | 382.8 us | 1x |
| Fastify | 2,975 | 336.1 us | 1.14x |
| Fastify + schema | 3,079 | 324.8 us | 1.18x |
| Workload | CPU/req | framework share | Fastify advantage |
|---|---|---|---|
| A. empty | 42.1 us | ~100% | 4.37x |
| B. point lookup | 66.8 us | ~63% | 1.92x |
| C. 100-row list | 382.8 us | ~11% | 1.14x |
Framework overhead measured on the empty handler is held constant for B and C, with the rest attributed to handler work. That decomposition is a model, not a direct measurement, and section "Known asymmetries" below says where it frays.
Waiting on a socket is idle time and never touches the event loop. What shrinks the framework's advantage is CPU work: result parsing, object construction, and above all serialization. Response size predicts whether the framework matters far better than query latency does.
Most published benchmarks run a fixed number of requests, usually 100,000. This one uses a fixed duration. Running both, three times each:
| Express | Fastify | |
|---|---|---|
-d 10 (fixed duration) |
15,355 | 30,541 |
-a 100000 (fixed count) |
14,285 | 25,001 |
| difference | -7.0% | -18.1% |
The reported ratio moves from 1.99x to 1.75x purely by changing when the benchmark stops.
The obvious explanation is that a 4-second run is too short. It is wrong. A fixed-duration run of the same length reports the full rate:
| Fastify, fixed duration | 2s | 4s | 6s | 10s | 20s |
|---|---|---|---|---|---|
| req/s | 28,816 | 30,040 | 30,011 | 30,671 | 30,876 |
The difference is in how the run ends. With a fixed count the 50 connections each get a share of the total and do not finish together, so the run ends with fewer and fewer connections active. That low-intensity tail is still averaged in. The arithmetic is exact:
| at its sustained rate | actually took | unaccounted | |
|---|---|---|---|
| Fastify | 3.27 s | 4.00 s | 0.73 s (18% of the run) |
| Express | 6.51 s | 7.00 s | 0.49 s (7% of the run) |
18% and 7% are exactly the measured throughput penalties. The effect shrinks as the run lengthens, which is why 300,000 requests converges on the fixed-duration figure.
Both numbers are real, they answer different questions. "How long to complete 100,000 requests" is legitimately answered by a fixed count. "What is this process's sustained throughput" is not. The trap is that both get reported in the same unit.
A note on the 250ms bucket profile: an earlier version of this repo used it as the evidence for the tail. It is not reliable. The final bucket is truncated wherever the run happens to stop, so its value swung between 1,000 and 10,000 req/s across runs while the arithmetic above stayed stable. Use the arithmetic.
These are disclosed rather than tuned away, because both are default behavior and default behavior is what most people run.
Express sets X-Powered-By and computes a weak ETag (a hash over the
response body); Fastify does neither. On the 27 KB response that is worth about
16 us per request:
Express on /list |
req/s | CPU/req |
|---|---|---|
| default | 2,593 | 385.5 us |
EXPRESS_LEAN=1 (both off) |
2,705 | 369.7 us |
So roughly a third of Express's disadvantage on the large response is a feature
Fastify does not provide, and the ratio moves from 1.15x to 1.10x. Set
EXPRESS_LEAN=1 to compare the frameworks rather than their defaults.
The absolute gap is not frozen. On the empty handler Fastify is 32.5 us per request cheaper; on the 27 KB list the gap is about 47 us. At that size the response-writing path itself differs, not just routing. What tracks the advantage is the framework's share of total CPU, not a fixed microsecond figure.
The database is local. Query time is around 0.2 ms including planning, so the connection pool never became the bottleneck. Against a database across a network you would need far more concurrency to reach these rates. What does transfer is the per-request CPU cost and the saturation ceiling: those are properties of the framework and your payload, not of the network.
src/server.js one server, three configurations, /__elu and /__whoami
sql/schema.sql DDL and 100k row seed, loaded on container start
bench/lib.js spawn, verify, warm up, measure, ELU window
bench/sweep.js the main run (A, B sweep, C)
bench/stopping-rule.js fixed duration vs fixed count, plus the profile
bench/naive-settimeout.js the version that produced a WRONG conclusion, kept on purpose
bench/analyze.js derived numbers from results/sweep.json
results/ raw JSON output
bench/naive-settimeout.js simulates I/O with setTimeout instead of hitting a
database. It suggests that database latency dilutes the framework advantage,
which is false. It is kept so the mistake is reproducible too.
MIT