open atlas
↑ Back to track
Node.js, zero to senior NODE · 05 · 02

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.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

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.

DimensionExpressFastify
Compositionlinear middleware chainplugins + lifecycle hooks
Handler signature(req, res, next)async (req, reply)
Validationbring your own (zod/joi)built-in JSON Schema
SerializationJSON.stringifyfast-json-stringify (compiled)
Ecosystemlargest, oldestlarge, plugin-based
Throughput (own bench)baseline~2x on simple JSON
Async handler errorsExpress 4: not auto-caughtawaited & 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.

Pick the best fit

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?

Quiz

What does fast-json-stringify actually do, and why is it Fastify's most real-world-relevant performance feature?

Quiz

An async route handler in an Express 4 app awaits a DB call that rejects, and there is no try/catch. What happens?

Order the steps

Order a Fastify request's journey through the lifecycle, from arrival to response:

  1. 1 Request arrives; the router matches it to a route and its schema
  2. 2 onRequest / preParsing / preValidation hooks run for this scope
  3. 3 The request schema validates and coerces params, query, and body
  4. 4 preHandler hooks run, then your async handler executes
  5. 5 The response schema's fast-json-stringify serializer encodes the reply
  6. 6 The serialized response is sent back to the client
Recall before you leave
  1. 01
    Fastify 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?
  2. 02
    Why can an async route handler silently crash an Express 4 service, and what are the two ways to fix it?
Recap

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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.