Express vs Fastify: middleware chain vs lifecycle hooks
How do Express and Fastify each turn a request into a response, and when should I pick one over the other?
Express runs one ordered list of (req, res, next) functions until something ends the response. Fastify routes first, then runs a fixed series of typed hooks around a handler, validating input and serializing output from JSON Schema.
THE MENTAL MODEL
Express is a conveyor belt you assemble yourself: every app.use and route handler is a station, order of registration is the only structure, and an error jumps the belt to the first station with four parameters. That simplicity is why it runs everywhere and why behaviour lives in conventions. Fastify is a fixed assembly line with named slots (onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse) and a router that picks the line before any of them run. Schemas are part of the route, so validation and fast serialization are compiled up front, and plugins create encapsulated scopes so a hook or decorator registered for /admin does not leak to /public. In Express you decide the order; in Fastify you decide which slot.
HOW IT FITS TOGETHER
- node:http 'request' eventExpress wraps req and res with helpers (req.params, res.json) and starts at the first registered layer.
- App middleware, in orderLogging, helmet, cors, express.json(). Each must call next() or end the response, or the request hangs.
- Router matches path + methodpath-to-regexp 8 syntax: /users/:id, named wildcards /*splat, optional {.:ext}. Non-matching layers are skipped.
- Route handlersOne or more (req, res, next) functions. next('route') jumps to the next matching route.
- Response endsres.json / res.send / res.end. Nothing after this can change status or headers.
- 404 fallthroughNo layer ended the response: your catch-all app.use, or the built-in final handler's 404.
- Error middleware (4 args)(err, req, res, next), registered last. Reached via next(err), sync throws and, in Express 5, rejected promises.
- Routing (find-my-way)Radix-tree lookup by method and path. No match goes to setNotFoundHandler, which runs the full lifecycle.
- onRequestAuth, rate limits, request IDs. request.body is always undefined here.
- preParsing, then parsingHook sees the raw payload stream (decompress, hash). Then the content-type parser builds request.body.
- preValidation, then validationAjv v8 checks params, querystring, headers and body against the route schema. Failure is a 400.
- preHandlerLast stop before your code: load the user, check permissions with typed, validated input.
- HandlerReturn a value from an async handler or call reply.send(). Throwing goes to the error handler.
- preSerialization, onSendResponse schema compiles to fast-json-stringify; onSend may replace the serialized payload.
- Response sent, onResponseonResponse runs after the bytes are written: metrics and logs, not changes.
| Express 5 | Fastify 5 | |
|---|---|---|
| Request model | Linear (req, res, next) middleware stack | Named lifecycle hooks around one handler |
| Routing | Ordered layers; path-to-regexp 8 syntax | find-my-way radix tree; conflicts detected at startup |
| Validation | None built in; add zod, express-validator, etc. | JSON Schema per route, compiled by Ajv v8; 400 on failure |
| Response serialization | JSON.stringify of whatever you pass | fast-json-stringify from response schema; unlisted fields omitted |
| Async errors | Rejected promises forwarded to error middleware (v5) | Thrown or rejected errors go to setErrorHandler |
| Plugins / encapsulation | Routers and middleware; everything is global by order | register() creates scopes; fastify-plugin opts out |
| Logging | None built in; pino-http or morgan | pino built in; request.log carries a request id |
| TypeScript | @types/express from DefinitelyTyped | Types ship with Fastify; type providers infer from schemas |
| Ecosystem | Largest; most Node middleware targets Express | @fastify/* plugins; @fastify/express or @fastify/middie to reuse middleware |
| Published benchmark claim | Express 5.2.1: about 27k req/s | Fastify 5.12.5: about 50k req/s |
KEY TERMS
- Middleware chain (Express)
- Every layer receives (req, res, next) and must either end the response or call next(). There is no separate place for auth, parsing or validation: their position in the list is their meaning, which is why moving one app.use line can open a security hole.
- Error middleware and Express 5 async errors
- Error handlers are identified by arity: exactly four parameters (err, req, res, next), registered after the routes. Express 5 forwards rejected promises and async throws to them; Express 4 did not, which is why express-async-errors and asyncHandler wrappers exist. Errors thrown later in a timer or event emitter callback still escape.
- Lifecycle hooks (Fastify)
- onRequest → preParsing → preValidation → preHandler → handler → preSerialization → onSend → onResponse, plus onError, onTimeout and onRequestAbort. Use async hooks or the done callback, never both in one hook; to reply early from an async hook, send and return reply.
- Plugins and encapsulation
- fastify.register(plugin, opts) creates a child context. Decorators, hooks and nested plugins registered inside are visible to that context and its children only. Wrap shared infrastructure (db, auth decorators) in fastify-plugin to expose it to the parent; keep feature routes encapsulated.
- Schema-driven validation and serialization
- Route schemas for params, querystring, headers and body are compiled once by Ajv (defaults include coerceTypes: 'array', useDefaults and removeAdditional). A response schema is compiled to a fast-json-stringify function, which is faster than JSON.stringify and drops fields the schema does not list, preventing accidental leaks.
- Logging with pino
- Fastify({ logger: true }) gives structured JSON logs, a per-request child logger at request.log with a reqId, and automatic request/response lines. Express has no logger; pino-http adds the same per-request req.log.
IN YOUR STACK
TypeScript · Express 5 + zod Server
import express, { type ErrorRequestHandler } from "express";
import { z } from "zod";
const users = new Map([[1, { id: 1, name: "Ada" }]]);
const findUser = async (id: number) => users.get(id) ?? null;
const Params = z.object({ id: z.coerce.number().int().positive() });
const app = express();
app.use(express.json({ limit: "100kb" }));
app.get("/users/:id", async (req, res) => {
const parsed = Params.safeParse(req.params);
if (!parsed.success) return void res.status(400).json({ error: "invalid id" });
const user = await findUser(parsed.data.id); // a rejection reaches onError
if (user) res.json(user);
else res.status(404).json({ error: "user not found" });
});
app.use((req, res) => { res.status(404).json({ error: "not found" }); });
const onError: ErrorRequestHandler = (err, req, res, next) => {
if (res.headersSent) return next(err);
console.error(err);
res.status(500).json({ error: "internal error" });
};
app.use(onError);
app.listen(3000);- Validation is your choice of library, called explicitly in each handler or wrapped in a middleware; nothing stops a route from forgetting it.
- res.json(user) sends every property on the object. Map to a DTO yourself if the model carries secrets.
- Express 5 requires Node 18+, and req.body is undefined (not {}) when no body parser ran.
TypeScript · Fastify 5 Server
import Fastify from "fastify";
const users = new Map([[1, { id: 1, name: "Ada", passwordHash: "x" }]]);
const findUser = async (id: number) => users.get(id) ?? null;
const app = Fastify({ logger: true });
app.get<{ Params: { id: number } }>("/users/:id", {
schema: {
params: { type: "object", properties: { id: { type: "integer", minimum: 1 } }, required: ["id"] },
response: { 200: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" } } } },
},
}, async (req, reply) => {
const user = await findUser(req.params.id); // "42" already coerced to 42
if (!user) return reply.code(404).send({ error: "user not found" });
return user; // passwordHash is not in the schema, so it is never sent
});
app.setNotFoundHandler((req, reply) => reply.code(404).send({ error: "not found" }));
app.setErrorHandler((err, req, reply) => {
const status = (err as { statusCode?: number }).statusCode ?? 500;
if (status >= 500) req.log.error({ err }, "request failed");
reply.code(status).send({ error: status < 500 ? (err as Error).message : "internal error" });
});
await app.listen({ port: 3000, host: "0.0.0.0" });- An invalid id returns 400 before the handler runs, with a message such as "params/id must be integer"; validation errors carry statusCode 400 into setErrorHandler.
- listen() binds to localhost by default; containers need host: "0.0.0.0".
- For inferred types instead of the hand-written generic, use a type provider such as @fastify/type-provider-typebox.
TypeScript · Hono 4 + @hono/zod-validator Server
import { serve } from "@hono/node-server";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
const users = new Map([[1, { id: 1, name: "Ada" }]]);
const findUser = async (id: number) => users.get(id) ?? null;
const app = new Hono();
const idParam = zValidator("param", z.object({ id: z.coerce.number().int().positive() }), (result, c) => {
if (!result.success) return c.json({ error: "invalid id" }, 400);
});
app.get("/users/:id", idParam, async (c) => {
const { id } = c.req.valid("param"); // typed as number
const user = await findUser(id);
if (!user) throw new HTTPException(404, { message: "user not found" });
return c.json(user);
});
app.notFound((c) => c.json({ error: "not found" }, 404));
app.onError((err, c) => {
if (err instanceof HTTPException) return c.json({ error: err.message }, err.status);
console.error(err);
return c.json({ error: "internal error" }, 500);
});
serve({ fetch: app.fetch, port: 3000 });- Hono is built on Web Request/Response, so the same app runs on Node (via @hono/node-server), Bun, Deno, Cloudflare Workers and other edge runtimes.
- Middleware is an async onion (await next()), closer to Koa than to Express.
- On Node, serve() returns the node:http server; closing it on SIGTERM is your job.
TypeScript · NestJS (Express adapter) Server
import { Controller, Get, Injectable, Module, NotFoundException, Param, ParseIntPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
@Injectable()
class UsersService {
private users = new Map([[1, { id: 1, name: "Ada" }]]);
async findOne(id: number) { return this.users.get(id) ?? null; }
}
@Controller("users")
class UsersController {
constructor(private readonly users: UsersService) {}
@Get(":id")
async findOne(@Param("id", ParseIntPipe) id: number) { // "abc" -> 400 before this runs
const user = await this.users.findOne(id);
if (!user) throw new NotFoundException("user not found"); // -> 404 JSON
return user;
}
}
@Module({ controllers: [UsersController], providers: [UsersService] })
class AppModule {}
const app = await NestFactory.create(AppModule); // pass new FastifyAdapter() to switch
app.enableShutdownHooks();
await app.listen(3000);- Nest is an application framework (modules, DI, guards, pipes, interceptors) on top of an HTTP adapter: Express by default, Fastify via @nestjs/platform-fastify.
- Unhandled non-HTTP exceptions become {"statusCode":500,"message":"Internal server error"} via the built-in exception filter.
- Needs experimentalDecorators and emitDecoratorMetadata in tsconfig; Node's type stripping cannot run this file directly.
JavaScript · Express 5 Server
import express from "express";
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; this.expose = status < 500; }
}
async function createOrder({ sku, qty = 1 }) {
if (!Number.isInteger(qty) || qty < 1) throw new HttpError(422, "qty must be a positive integer");
return { id: crypto.randomUUID(), sku, qty }; // persist here; a rejection is handled too
}
const app = express();
app.use(express.json()); // malformed JSON -> error with status 400, expose true
app.post("/orders", async (req, res) => {
if (!req.body?.sku) throw new HttpError(400, "sku is required"); // no try/catch
res.status(201).json(await createOrder(req.body));
});
app.use((req, res) => res.status(404).json({ error: "not found" }));
app.use((err, req, res, next) => { // four params, even if next looks unused
if (res.headersSent) return next(err); // mid-stream: let Express abort the socket
const status = err.status ?? err.statusCode ?? 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.expose ? err.message : "internal error" });
});
app.listen(3000);- With three parameters Express treats the function as normal middleware and your errors fall through to the default HTML handler.
- The default handler uses err.status / err.statusCode and only shows stack traces when NODE_ENV is not production; set NODE_ENV=production.
- Only errors on the request's promise chain are caught: a throw inside setTimeout or an emitter callback still crashes the process.
JavaScript · Fastify 5 + fastify-plugin Server
import Fastify from "fastify";
import fp from "fastify-plugin";
// Shared infrastructure: fp() skips encapsulation so siblings see app.db.
const dbPlugin = fp(async (app, opts) => {
const db = { query: async (sql) => [{ sql, url: opts.url }] }; // stand-in for a real pool
app.decorate("db", db);
app.addHook("onClose", async () => { /* await pool.end() */ });
}, { name: "db" });
// Feature plugin: its hook and decorator stay inside this scope.
async function adminRoutes(app) {
app.decorateRequest("isAdmin", false);
app.addHook("onRequest", async (req, reply) => {
if (req.headers["x-admin-token"] !== process.env.ADMIN_TOKEN) {
return reply.code(401).send({ error: "unauthorized" });
}
req.isAdmin = true;
});
app.get("/stats", async () => ({ rows: await app.db.query("select 1") }));
}
const app = Fastify({ logger: true });
await app.register(dbPlugin, { url: process.env.DATABASE_URL });
await app.register(adminRoutes, { prefix: "/admin" });
app.get("/health", async () => ({ ok: true, hasDb: Boolean(app.db) })); // no admin hook
await app.listen({ port: 3000 });- GET /admin/stats runs the token check; GET /health does not, because the hook lives in the child context.
- Without fp(), app.db would exist only inside dbPlugin and every sibling plugin would see undefined.
- The name option lets other plugins declare dependencies on "db", and Fastify checks them at boot.
WHEN TO REACH FOR WHAT
Express 5
Use when You need maximum ecosystem and hiring familiarity, are maintaining an Express 4 app (the upgrade is mostly mechanical), or want the smallest concepts-to-code ratio for an internal service.
Avoid when You want validation, serialization and typed routes enforced by the framework, or per-instance throughput is a real cost driver.
Fastify 5
Use when You are building a JSON API on Node and want schema-first validation, safe response serialization, built-in structured logging and encapsulated plugins.
Avoid when The team depends on many Express-only middlewares, or you need to run the same code on edge runtimes without Node APIs.
Hono
Use when The code must run on several runtimes (Workers, Bun, Deno, Node, Lambda) or at the edge, and you like Web Request/Response plus typed validators and an RPC client.
Avoid when You rely on Node-specific middleware or streams in the request path, or need a deep plugin ecosystem for a large monolith.
NestJS (on Express or Fastify)
Use when A large team wants enforced structure: modules, dependency injection, guards, interceptors and first-class microservice and GraphQL integrations.
Avoid when The service is small, cold-start sensitive, or the team does not want decorators, reflection metadata and framework-specific abstractions.
Koa
Use when You want a tiny async-first core with onion middleware and are happy to pick router and body parser yourself.
Avoid when You want batteries included or a large, active plugin ecosystem; Fastify or Hono cover the same ground with more built in.
WHERE IT BITES
- Registering Express middleware in the wrong order: express.json() after the routes means req.body is undefined, and an auth middleware registered after a router never protects it.
- Using a three-argument error handler in Express, or registering it before the routes. It is then plain middleware and never sees errors.
- Calling done() inside an async Fastify hook, or replying from an async hook without returning reply, which leads to double invocation or FST_ERR_REP_ALREADY_SENT.
- Forgetting fastify-plugin on a plugin that decorates the instance, then wondering why app.db is undefined in sibling routes; or wrapping everything in fp() and losing encapsulation for scoped auth hooks.
- Treating framework benchmarks as capacity planning. Hello-world req/s numbers measure framework overhead; your database, serialization size and logging usually dominate.
- Sending ORM objects straight to res.json() in Express. Without a response schema or DTO mapping, new columns such as password hashes ship to clients automatically.
CLOSE THE AI. EXPLAIN THIS.
Trace GET /users/abc through both frameworks: where is it routed, where does the 400 come from, which code never runs, and where would an auth check and a request log line live in each?WHEN IT BREAKS IN PRODUCTION
SOURCES
- Express: Error handling (opens in new tab)Checked
- Express: Migrating to Express 5 (opens in new tab)Checked
- Fastify: Lifecycle (opens in new tab)Checked
- Fastify: Encapsulation (opens in new tab)Checked
- Fastify: Validation and Serialization (opens in new tab)Checked
- Fastify: Benchmarks (opens in new tab)Checked
Explainer reviewed