Fastify vs Express: What the 4× Actually Buys You

Fastify is 4.13× faster than Express on an empty handler. Put a real PostgreSQL query behind it and it becomes 2.04×. Return 27KB instead of 269 bytes and it becomes 1.16×. Full methodology, versions, dataset, and raw numbers — the variable turns out to be CPU, not database latency.

17 min read
  • Node.js
  • Fastify
  • Express
  • Performance
  • PostgreSQL
  • Benchmarking

Every Node framework comparison opens with a throughput table from an empty handler, and almost none of them tell you what to do with it. "Fastify is 3–5× faster than Express" is repeated so often that it has stopped carrying information.

So I measured it three times: on an empty handler, on a real PostgreSQL query returning one row, and on the same database returning 100 rows. The three numbers are 4.13×, 2.04×, and 1.16×.

The distance between them is the whole article, and the thing that moves it is not what most comparisons assume. Everything below — versions, schema, queries, server code, and the measurement protocol — is written out so you can disagree with the method rather than the conclusion.

What was measured

ComponentVersion
Node.jsv22.13.1
Express5.2.1
Fastify5.11.2
pg (node-postgres)8.22.0
autocannon (load generator)8.0.0
PostgreSQL15.10 (Debian aarch64, Docker)
OSmacOS (darwin 25.5.0)
CPUApple M4 Pro, 12 cores

Single Node process per framework — no cluster, no PM2, no reverse proxy, no TLS. Load generator and server on the same host over loopback. PostgreSQL in Docker on the same machine, reached over the published port. Connection pool capped at 10 for every run.

The dataset

100,000 rows of realistic user records — mixed scalar types, a jsonb column, and a text[] column, so serialization has something to do beyond flat strings.

sql

CREATE TABLE users (
  id           bigserial PRIMARY KEY,
  email        text NOT NULL,
  display_name text NOT NULL,
  role         text NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  last_seen_at timestamptz NOT NULL DEFAULT now(),
  settings     jsonb NOT NULL,
  tags         text[] NOT NULL
);

INSERT INTO users (email, display_name, role, settings, tags)
SELECT
  'user' || i || '@example.com',
  'User ' || i,
  (ARRAY['engineer','manager','analyst'])[1 + (i % 3)],
  jsonb_build_object('theme','dark','locale','en-GB','notifications', (i % 2 = 0)),
  ARRAY['backend','kafka','postgres']
FROM generate_series(1, 100000) AS i;

CREATE INDEX users_role_idx ON users(role);
ANALYZE users;

That is 100,000 rows, 24 MB including indexes. A single row comes back as:

json

{"id":"42","email":"user42@example.com","display_name":"User 42",
 "role":"engineer","created_at":"2026-08-06T09:42:37.480Z",
 "last_seen_at":"2026-08-06T09:42:37.480Z",
 "settings":{"theme":"dark","locale":"en-GB","notifications":true},
 "tags":["backend","kafka","postgres"]}

The queries, and what they actually cost

sql

-- point lookup
SELECT id, email, display_name, role, created_at, last_seen_at, settings, tags
FROM users WHERE id = $1;

-- list
SELECT id, email, display_name, role, created_at, last_seen_at, settings, tags
FROM users WHERE role = $1 LIMIT 100;

EXPLAIN (ANALYZE, BUFFERS) on both:

QueryPlanPlanningExecution
Point lookupIndex Scan using users_pkey0.183 ms0.035 ms
List (100 rows)Seq Scan + Limit0.159 ms0.051 ms

Two things to be upfront about. First, the database is genuinely fast here — around 0.2ms server-side including planning — because it is local and the working set is in cache. That is the main caveat of the whole exercise and I come back to it at the end. Second, the planner ignores users_role_idx for the list query and chooses a sequential scan: one row in three matches role = 'engineer', so with LIMIT 100 it finds enough rows almost immediately and the index is not worth it. That is the correct plan, and it is identical for both frameworks, which is all that matters for a comparison.

The three workloads

WorkloadHandler doesResponse size
A — emptyreturns a static object, no I/O269 B
B — point lookupone indexed query, one row269 B
C — listone query, 100 rows27,243 B

A and B return byte-identical payloads. The only difference between them is whether a database round-trip happened. That isolation is the point: A→B measures what the driver costs, B→C measures what payload size costs.

The servers

Identical routes, identical query, identical pool. The only variable is the framework.

js

const { Pool } = require("pg");
const pool = new Pool({
  host: "127.0.0.1", port: 5432, user: "admin",
  password: "password", database: "bench_fe", max: 10,
});

const POINT_SQL = "SELECT id, email, display_name, role, created_at, last_seen_at, settings, tags FROM users WHERE id = $1";
const LIST_SQL  = "SELECT id, email, display_name, role, created_at, last_seen_at, settings, tags FROM users WHERE role = $1 LIMIT 100";

const pointQuery = async (id) => (await pool.query(POINT_SQL, [Number(id) % 100000 || 1])).rows[0];
const listQuery  = async ()   => (await pool.query(LIST_SQL, ["engineer"])).rows;

js

// Express 5
const app = express();
app.get("/users/:id", async (req, res, next) => {
  try { res.json(await pointQuery(req.params.id)); } catch (e) { next(e); }
});
app.get("/list", async (_req, res, next) => {
  try { res.json(await listQuery()); } catch (e) { next(e); }
});
app.listen(PORT);

js

// Fastify 5 — note listen({ port }), not listen(port)
const app = Fastify({ logger: false });
app.get("/users/:id", opts, (req) => pointQuery(req.params.id));
app.get("/list", opts, () => listQuery());
await app.listen({ port: PORT, host: "127.0.0.1" });

A third configuration adds a JSON Schema to opts so Fastify compiles a fast-json-stringify serializer:

js

const userSchema = {
  type: "object",
  properties: {
    id: { type: "string" },   // bigserial arrives from pg as a string
    email: { type: "string" },
    display_name: { type: "string" },
    role: { type: "string" },
    created_at: { type: "string" },
    last_seen_at: { type: "string" },
    settings: {
      type: "object",
      properties: {
        theme: { type: "string" },
        locale: { type: "string" },
        notifications: { type: "boolean" },
      },
    },
    tags: { type: "array", items: { type: "string" } },
  },
};
// route: { schema: { response: { 200: userSchema } } }

The measurement protocol

For every framework × workload × concurrency combination:

  1. Start the server in a fresh process (no cross-contamination of JIT state or pool warmth).
  2. Run autocannon for 3 seconds and discard it — this fills the connection pool, warms Postgres' cache, and lets V8 reach steady state.
  3. Call /__elu/start on the server.
  4. Run autocannon for 10 seconds and record it.
  5. Call /__elu/stop and record event loop utilization for exactly that window.
  6. SIGKILL the server, wait, move to the next combination.

Step 3–5 is the part most benchmarks skip, and it is four lines:

js

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

eventLoopUtilization() returns the fraction of wall time the loop was active rather than idle. Throughput alone cannot tell you whether a number stopped rising because the framework ran out of CPU or because something else was the bottleneck. ELU can. And it gives the number that actually transfers between machines:

code

microseconds of event-loop CPU per request = (ELU × 1,000,000) ÷ requests_per_second

Concurrency was swept at 10, 50, 200, and 500 connections instead of fixed at one value — a single connection count tells you a point, not a ceiling. Every run below completed with zero non-2xx responses and zero errors.

Result A — empty handler

Empty handlerreq/sCPU per requestvs Express
Express 5.2.124,10041.5 µs
Fastify 5.11.299,62210.0 µs4.13×
Fastify + response schema104,2369.6 µs4.33×

There is the famous number, reproduced. Fastify saves roughly 31 µs of event-loop CPU per request.

The gap is not magic and it is not "Express is badly written." It is accumulated API surface. Fastify resolves routes through a radix tree (find-my-way) instead of walking an ordered stack and running a regex per layer. Its plugin encapsulation means a route runs only the hooks in its own scope, where every app.use in Express is traversed by every request. Its request object stays lean rather than being decorated with helpers and getters on each call. With a response schema it compiles a purpose-built serializer instead of reflecting over the object at runtime.

All real. The question is what 31 µs is a share of.

Result B — a real query, swept across concurrency

Same 269-byte response, now fetched from PostgreSQL.

ConnectionsExpress req/sELUp50 / p99Fastify req/sELUp50 / p99Ratio
1014,69999.7%0 / 1 ms27,14794.2%0 / 0 ms1.85×
5015,070100%3 / 4 ms30,74898.9%1 / 2 ms2.04×
20014,917100%13 / 15 ms29,83598.9%6 / 9 ms2.00×
50014,05799.9%35 / 46 ms29,79799.2%16 / 20 ms2.12×

Two findings, and the second is the one that corrected my own first draft.

Both frameworks are event-loop bound, and concurrency buys nothing. Express pins ELU at 100% and sits at ~15,000 req/s from 10 connections all the way to 500. Going from 10 to 500 connections does not add a single request per second — it adds 35 ms of queueing to p50. That is the signature of a saturated event loop, and it is worth recognising in production: once ELU is at 100%, additional concurrency converts directly into latency.

The advantage halved, from 4.13× to 2.04× — not because the database is slow, but because the driver's work costs CPU on the same single thread. Per-request event-loop CPU went from 41.5 → 66.4 µs for Express and 10.0 → 32.2 µs for Fastify. The framework's ~31 µs did not change. Everything around it got more expensive.

Result C — same database, bigger response

One change: return 100 rows (27,243 bytes) instead of one (269 bytes).

100 rows, 50 connectionsreq/sCPU per requestELUp99vs Express
Express2,628380.5 µs100%21 ms
Fastify3,041328.8 µs100%19 ms1.16×
Fastify + response schema3,104322.2 µs100%19 ms1.18×

At 380 µs of CPU per request, a 31 µs saving is 11% — and 11% is what the measurement shows.

Reading all three together

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

WorkloadTotal CPU/reqFramework shareFastify advantage
A — empty handler41.5 µs~100%4.13×
B — point lookup66.4 µs~63%2.04×
C — 100-row list380.5 µs~11%1.16×

That also explains why the benchmark charts you see quoted everywhere — Fastify's own published figures put Express around 10,500 req/s and Fastify around 45,000, a ratio of ~4.3× — line up almost exactly with workload A here. They are router measurements. They are not wrong; they are just answering a question about an empty handler, and being cited as though they answered one about a service.

The response schema is not the reason to switch

fast-json-stringify is the single most-cited reason to adopt Fastify, so it deserves its own honest line. Across every workload:

  • Empty handler, 269-byte object: +4.6% over plain Fastify
  • Point lookup: within run-to-run noise, occasionally slower
  • 100-row list, 27 KB: +2.1%

It is not nothing, and it grows with deeply nested or polymorphic payloads where JSON.stringify has more type-checking to do. But V8's JSON.stringify is extremely well optimised. If you adopt Fastify expecting serialization to be the win, measure your own payloads before promising anyone a number.

Adopt schemas for what they reliably give: validation, an honest OpenAPI document, and a contract that fails loudly at the boundary. Treat the speed as a rounding error unless your own measurement says otherwise.

So when is the framework the right lever?

When the handler is thin and volume is high. API gateways, edge auth and token introspection, telemetry and event ingest, webhook receivers, proxies, anything fronting a cache. There the framework genuinely is the workload, and 2–4× is 2–4×.

When you are sizing capacity. The ceilings above are per process: ~15,000 req/s for Express, ~30,000 for Fastify on this hardware with this payload. That ceiling does not move when your database gets slower — a slower database just means more concurrency is needed to reach it. Which is exactly when you least want to find it by surprise.

When it is not the lever: if your p99 is 180 ms, the framework contributes about 0.02% of it. The cause will be a missing index, an N+1, a pool sized wrong, or an oversized payload — and note that the oversized payload is the one item on that list that also makes the framework choice irrelevant. Fixing it pays twice.

What should actually drive the choice

Schema-first by default. Fastify takes JSON Schema for body, params, query, and response and uses it for validation and serialization. In Express you assemble that yourself with Zod or Ajv — not harder, but re-decided per team, and it drifts.

Encapsulation vs. globals. Fastify plugins scope hooks and decorators to a subtree. Express middleware is a global ordered list, and in a large app "which middleware runs for this route, in what order" becomes genuinely hard to answer.

TypeScript. Fastify's route generics give typed request.body without casting. Express's types are serviceable; you will hand-write more interfaces.

Ecosystem breadth. Express still wins outright — more middleware, more answers, more guides that assume it. Much of it runs under @fastify/middie, but not the parts that reach into req/res internals.

Express 5 closed the worst gap. This is worth verifying rather than repeating. Both a synchronous throw and an async rejection now reach the centralised error handler:

js

app.get("/sync",  (req, res) => { throw new Error("boom"); });
app.get("/async", async (req, res) => { throw new Error("boom"); });
app.use((err, req, res, next) => res.status(500).json({ caught: err.message }));
// both → 500 {"caught":"boom"} on Express 5.2.1

If "async errors vanish in Express" was your reason to migrate, re-check it against Express 5 before spending the quarter.

What your team already knows. A team fluent in Express shipping correct services is worth more than 31 µs. That is not a tiebreaker; it is frequently the whole answer.

If you do migrate

Not a drop-in. req/res semantics differ, middleware becomes hooks and plugins with different visibility rules, error handling and content-type parsing are configured differently, and every test touching the app instance gets rewritten.

The low-risk path is the strangler: stand the Fastify app beside the old one, route new endpoints to it at the proxy, and move existing ones only when you are already in there. A big-bang rewrite of a working service to reclaim 31 µs per request is hard to justify to anyone counting.

The checklist

  1. Measure your handler's CPU cost, not its latency: ELU × 1e6 ÷ req_per_sec gives microseconds per request.
  2. Compare ~31 µs against that number. That ratio is the most the framework can give you.
  3. Check your response sizes before anything else — they move the answer more than the database does.
  4. If ELU is pinned near 100%, more connections will only add latency; you need less CPU per request or another process.
  5. Thin handlers at high volume: Fastify is a real architectural lever.
  6. Fat payloads or heavy handlers: choose on schemas, encapsulation, types, and ecosystem instead.
  7. Existing Express 5 service that is fast enough: leave it alone and go fix the query plan.

Reproduce it

bash

mkdir bench && cd bench && npm init -y
npm i express@5.2.1 fastify@5.11.2 pg@8.22.0 autocannon@8.0.0

docker run -d --name bench-pg -p 5432:5432 \
  -e POSTGRES_PASSWORD=password -e POSTGRES_USER=admin -e POSTGRES_DB=bench_fe \
  postgres:15
# apply the schema + seed from "The dataset" above

FRAMEWORK=fastify node server-pg.js &
npx autocannon -c 50 -d 3  http://127.0.0.1:4010/users/42   # warm-up, discard
curl -s http://127.0.0.1:4010/__elu/start
npx autocannon -c 50 -d 10 http://127.0.0.1:4010/users/42   # measure
curl -s http://127.0.0.1:4010/__elu/stop

Then swap in your own query and your own payload. The useful benchmark is never the framework's — it is yours.