Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fastify-vs-express-benchmark

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

Quick start

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:down

Results are written to results/*.json.

What is measured

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).

Method

  • 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 /__whoami confirms 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.

Results

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.

A. Empty handler, 50 connections

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

B. Point lookup, connection sweep

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.

C. 100-row list, 50 connections

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

The framework's share of Express's per-request CPU

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.

The stopping rule changes the answer

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.

Known asymmetries

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.

Layout

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.

License

MIT

About

Fastify vs Express measured against a real PostgreSQL database, with event loop utilization sampling

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages