Express vs Fastify: middleware and throughput
Express is a linear (req, res, next) middleware chain — minimal and unopinionated with the largest ecosystem. Fastify trades that for a plugin model with JSON-Schema validation and fast-json-stringify serialization, the real throughput win. Both are production-grade.
A team migrated a JSON API to Fastify after a blog post promised “2x throughput,” then watched their p99 latency stay exactly where it was. The benchmark was real — Fastify does serve roughly twice the requests/sec of Express on a tiny “return a JSON object” route — but their handler spent 40ms waiting on Postgres and 0.2ms in the framework. The framework was never the bottleneck. Worse, two routes silently broke: an Express 4 async handler that used to throw a 500 now leaked the rejection as an unhandledRejection, because nobody had wrapped it. They chased a number that didn’t matter and shipped a bug that did.
By the end of this lesson you’ll understand what each framework actually trades — so you can choose the right one for the right reasons, not chase a benchmark number.
Two philosophies: a chain vs an encapsulated plugin tree
Before picking a framework, ask yourself: do you need the widest possible ecosystem and the simplest mental model, or do you need schema-driven validation and serialization baked in? The answer usually decides the choice. Express is deliberately small: a request flows through a linear chain of middleware, each a (req, res, next) function that mutates req/res and calls next() to pass control along, or next(err) to jump to an error handler. There is almost no opinion baked in — you assemble routing, body parsing, auth, and logging from a vast ecosystem of app.use() middleware. That minimalism is its superpower: the largest middleware catalogue in the Node world, a decade of battle-testing, and a signature every Node developer already knows.
import express from "express";
const app = express();
// global middleware: runs for every request, in registration order
app.use(express.json());
app.use((req, res, next) => { req.startedAt = Date.now(); next(); });
app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});Fastify makes a different bet: a request runs through lifecycle hooks (onRequest, preParsing, preValidation, preHandler, …) and routes are grouped into plugins registered with fastify.register(). The defining feature is encapsulation — a plugin gets its own scope, so decorators, hooks, and middleware registered inside it do not leak to siblings. This is a feature for modularity, but it surprises Express refugees who expect app.use() to be global everywhere.
import Fastify from "fastify";
const app = Fastify({ logger: true }); // pino built in
await app.register(async (instance) => {
// this hook only runs for routes inside this plugin scope
instance.addHook("preHandler", async (req) => { req.startedAt = Date.now(); });
instance.get("/users/:id", async (req) => ({ id: req.params.id }));
});The real differentiator: schema-driven validation and serialization
Express has no built-in validation or serialization story — you reach for zod, joi, or express-validator, and you serialize with res.json(), which calls JSON.stringify. Fastify builds both into the route definition through JSON Schema. A request schema validates and coerces incoming params/query/body before your handler runs. A response schema is the throughput lever: fast-json-stringify compiles that schema into a specialized serializer function ahead of time, so it never walks an unknown object shape at runtime the way generic JSON.stringify must.
app.get("/users/:id", {
schema: {
params: { type: "object", properties: { id: { type: "string" } } },
response: {
200: {
type: "object",
properties: { id: { type: "string" }, name: { type: "string" } },
},
},
},
}, async (req) => getUser(req.params.id));That compiled serializer is the single most real-world-relevant win, because it speeds up every response, not just a synthetic empty route — and as a bonus, fields not in the response schema are stripped, so you can’t accidentally leak a passwordHash.
| Dimension | Express | Fastify |
|---|---|---|
| Composition | linear middleware chain | plugins + lifecycle hooks |
| Handler signature | (req, res, next) | async (req, reply) |
| Validation | bring your own (zod/joi) | built-in JSON Schema |
| Serialization | JSON.stringify | fast-json-stringify (compiled) |
| Ecosystem | largest, oldest | large, plugin-based |
| Throughput (own bench) | baseline | ~2x on simple JSON |
| Async handler errors | Express 4: not auto-caught | awaited & routed to error hook |
▸Why this works
Why is Fastify “~2x” in benchmarks but flat in production? Its own benchmark suite hammers a route that returns a small constant object, so the only work is HTTP parsing and JSON serialization — exactly where the compiled serializer and a lean router shine. A real handler adds a DB round-trip (often 5–40ms) and external calls that dwarf the framework’s sub-millisecond overhead. Synthetic numbers measure the framework in isolation; your latency is dominated by IO. The throughput edge is genuine, but it matters most for high-RPS, response-heavy JSON gateways, not CRUD apps bound by a database.
The Express 4 async-error trap
The sharpest failure mode is historical and still everywhere. Express 4 does not catch rejected promises from async route handlers. If an async handler throws (or awaits something that rejects), Express 4 has no await around your function, so the rejection becomes an unhandledRejection — which since Node 15 crashes the process by default — instead of reaching your error-handling middleware.
// Express 4 — BROKEN: rejection escapes to unhandledRejection
app.get("/u/:id", async (req, res) => {
const user = await db.find(req.params.id); // if this rejects…
res.json(user); // …Express 4 never catches it
});
// Express 4 — FIX: try/catch + next, or a wrapper like express-async-errors
app.get("/u/:id", async (req, res, next) => {
try { res.json(await db.find(req.params.id)); }
catch (err) { next(err); } // hand off to the error middleware
});Express 5 (now released) fixes this: rejected promises from async handlers are forwarded to the error handler automatically, closing the gap. Fastify never had this problem — handlers are async by design, their rejections are awaited and routed to the setErrorHandler hook. If you are on Express 4, wrap every async handler (or adopt express-async-errors); the silent crash is the most common production bug in older Express apps.
You're building a new high-RPS public JSON API: many small read endpoints, strict input validation, a team comfortable picking up TypeScript, and a need to never leak internal fields in responses. Which framework fits best?
What does fast-json-stringify actually do, and why is it Fastify's most real-world-relevant performance feature?
An async route handler in an Express 4 app awaits a DB call that rejects, and there is no try/catch. What happens?
Order a Fastify request's journey through the lifecycle, from arrival to response:
- 1 Request arrives; the router matches it to a route and its schema
- 2 onRequest / preParsing / preValidation hooks run for this scope
- 3 The request schema validates and coerces params, query, and body
- 4 preHandler hooks run, then your async handler executes
- 5 The response schema's fast-json-stringify serializer encodes the reply
- 6 The serialized response is sent back to the client
- 01Fastify reports ~2x the throughput of Express in its own benchmarks. Why is that often invisible in a real application, and when does it actually matter?
- 02Why can an async route handler silently crash an Express 4 service, and what are the two ways to fix it?
Express and Fastify solve the same problem with opposite temperaments. Express is a minimal, unopinionated linear middleware chain of (req, res, next) functions with the largest ecosystem in Node and a decade of battle-testing — you assemble validation, serialization, and everything else from third-party middleware. Fastify is a plugin-and-hooks model whose plugins are encapsulated (so app.use()-style globals don’t leak across scopes, which surprises Express refugees) and whose defining feature is JSON Schema: request schemas validate and coerce input, and a response schema compiles a fast-json-stringify serializer that speeds every JSON response and strips fields not in the schema. On performance, Fastify’s own benchmarks show roughly 2x the requests/sec of Express, but that measures the framework in isolation on a trivial route; in real apps a 5–40ms database round-trip dominates the sub-millisecond framework overhead, so the genuine, portable win is the compiled serializer, not the headline number. The classic failure mode is Express 4 not catching rejections from async handlers — they escape as an unhandledRejection and crash the process since Node 15, fixed by try/catch + next(err), a wrapper like express-async-errors, or upgrading to Express 5 (which forwards them automatically, as Fastify always has). Pick Express for ecosystem breadth, team familiarity, and gradual adoption; pick Fastify for performance-sensitive JSON APIs, schema-driven validation/serialization, and a structured plugin model — both are production-grade. Now when you see a benchmark claiming “2x throughput,” you’ll know to ask what the handler actually does before reaching for a migration ticket.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.