Request handling models: Express middleware chain vs Fastify lifecycle hooks
Express treats a request as a single ordered list of functions. Each middleware receives req, res and next, and either ends the response or calls next() to pass control on. There are no phases, so registration order is the architecture. A body parser mounted after a route never runs for that route. An auth check mounted after a router does not protect that router. Errors skip ahead to middleware with four parameters, (err, req, res, next), and Express identifies these by the function's arity. Dropping the unused next silently turns an error handler into a normal middleware.
Fastify defines named lifecycle stages and lets you attach hooks to each one.
| Express | Fastify |
|---|---|
| middleware 1 | onRequest |
| middleware 2 | preParsing |
| body parser (if mounted) | preValidation → schema validation |
| route handler | preHandler → handler |
| error middleware (4 args) | preSerialization → onSend |
| onResponse (after send) | |
| onError (on failure) |
The Express model makes ad hoc composition easy. Any function can sit anywhere, and per-route middleware arrays need no extra concepts. The cost is that nothing tells a reader whether a function runs before or after parsing. The Fastify model makes placement predictable. An onRequest hook runs before the body is read, and a preHandler hook can rely on validated input. The cost is that you must learn which stage owns which concern.
Async errors also differ. Fastify catches a rejected promise from an async handler or hook and routes it to its error handling. Express behavior depends on the major version. Check your version's documentation, or wrap async handlers so rejections reach next(err).
// Express: order is the architecture; error handlers need 4 args
const express = require('express');
const app = express();
app.use(express.json()); // must be mounted before routes that read req.body
// Wrapper so a rejected promise reaches next(err) on any Express version
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.post('/orders', wrap(async (req, res) => {
if (!req.body.sku) throw new Error('missing sku');
res.status(201).json({ ok: true });
}));
// Keep all four parameters, even if next is unused, or Express ignores it for errors
app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
// Fastify: hooks attach to named stages regardless of registration position
const fastify = require('fastify')();
fastify.addHook('onRequest', async (request, reply) => {
// Runs before the body is read; a good place for cheap auth checks
if (!request.headers.authorization) reply.code(401).send({ error: 'unauthorized' });
});
fastify.post('/orders', {
schema: { body: { type: 'object', required: ['sku'], properties: { sku: { type: 'string' } } } },
preHandler: async (request) => { request.log.info({ sku: request.body.sku }, 'validated'); }
}, async (request, reply) => {
// A thrown error or rejected promise goes to Fastify's error handler natively
reply.code(201);
return { ok: true };
});
app.listen(3000);
fastify.listen({ port: 3001 });
The same route in Express, with order-dependent middleware, an async wrapper and a four-argument error handler, and in Fastify, with stage-based hooks, schema validation and native async error handling.
How Fastify plugin encapsulation works
Calling fastify.register(plugin, opts) does more than run a function. Fastify creates a new child context for that plugin: a fresh instance that inherits everything the parent has declared, but whose own additions stay local. Decorators added with decorate, hooks added with addHook, and routes all attach to that child. The parent never sees them, and neither does any sibling registered alongside it. Inheritance flows down the tree only.
This is the source of a common early error. A newcomer registers a plugin that does fastify.decorate('db', client), then registers a second plugin with the routes and calls fastify.db.query(...) inside a handler. The second plugin is a sibling, so fastify.db is undefined, and the request fails with something like "Cannot read properties of undefined (reading 'query')." Nothing is misconfigured. The scoping is working as designed. It exists so that a plugin's auth hook or custom content-type parser cannot leak into unrelated routes.
For utilities that every part of the app should share, such as a database client, a config object, or a metrics registry, you wrap the plugin with fastify-plugin. The wrapper tells Fastify to skip creating a new context and apply the plugin's decorators and hooks to the parent instead. You use it deliberately and only for shared infrastructure.
Express has no equivalent enforcement. express.Router() groups routes and scopes router.use middleware to that router's paths. Shared state is usually handled through app.locals, res.locals, or plain module imports. All of these work, but the boundaries depend on team convention, and nothing stops one module from mutating app.locals that another module relies on.
const Fastify = require('fastify')
const fp = require('fastify-plugin')
const app = Fastify({ logger: true })
// Stand-in for a real database client
const client = { query: async (sql) => [{ id: 1, name: 'placeholder' }] }
// Encapsulated plugin: 'db' is attached to this plugin's child context only.
// The parent and sibling plugins never see it.
async function dbPluginScoped (fastify, opts) {
fastify.decorate('db', client)
}
// The same plugin wrapped with fastify-plugin: Fastify skips the child
// context, so 'db' is added to the parent and every plugin registered
// after it inherits the decorator.
const dbPluginShared = fp(async function dbPlugin (fastify, opts) {
fastify.decorate('db', client)
})
// Sibling plugin that depends on fastify.db
async function userRoutes (fastify, opts) {
fastify.get('/users', async () => {
// With dbPluginScoped registered above, fastify.db is undefined here:
// TypeError: Cannot read properties of undefined (reading 'query')
return fastify.db.query('SELECT id, name FROM users')
})
}
// Swap in dbPluginScoped to reproduce the 'undefined' error
app.register(dbPluginShared)
app.register(userRoutes)
app.listen({ port: 3000 }).catch((err) => {
app.log.error(err)
process.exit(1)
})
Two versions of the same database plugin. The plain one keeps fastify.db inside its own context, so the sibling routes plugin gets undefined. The version wrapped with fastify-plugin adds the decorator to the parent so every plugin can use it.
Schema-first validation and response serialization in Fastify
Fastify moves input checking out of handler code and into route configuration. Each route accepts a schema object with separate JSON Schema definitions for body, querystring, params and headers. Fastify compiles these with Ajv when routes are registered, so each request runs a prebuilt validator function rather than interpreting the schema again. A malformed schema fails at boot instead of on the first unlucky request. By default, Fastify's Ajv setup also coerces types. A query string value like ?limit=20 reaches the handler as a number when the schema declares integer.
When validation fails, the handler never runs. The client gets a 400 with a JSON body containing statusCode, error ("Bad Request") and a message such as body must have required property 'email'. Many teams replace this with a custom error handler to match an existing API error contract. Fastify's default format is consistent, but it is not designed for end users to read.
A response schema, keyed by status code, does two things. Fastify uses it to generate a serializer specialized to that shape, which avoids generic JSON.stringify work. The serializer also outputs only declared properties. That stops an accidental passwordHash from leaking, but it cuts both ways. If you add a field to the handler and forget to update the schema, the field disappears silently, with no error or warning.
Tools like TypeBox reduce this drift. You define a schema once and derive the TypeScript type from it, so the handler's types and the runtime contract can't diverge. The cost is another schema DSL your team has to learn, on top of JSON Schema's own quirks.
import Fastify from 'fastify';
import { Type } from '@sinclair/typebox';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
// The type provider derives handler types from the same TypeBox schemas Ajv validates against
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
// Public shape of a user. Only these properties are serialized.
const UserResponse = Type.Object({ id: Type.String(), email: Type.String() });
app.post('/orgs/:orgId/users', {
schema: {
params: Type.Object({ orgId: Type.String() }),
// Default coercion turns ?limit=20 into the number 20 before the handler runs
querystring: Type.Object({ limit: Type.Integer({ minimum: 1, maximum: 100, default: 20 }) }),
// Header names in the schema must be lowercase
headers: Type.Object({ 'x-request-id': Type.String() }),
body: Type.Object({
email: Type.String({ minLength: 3 }),
password: Type.String({ minLength: 12 }),
}),
// Keyed by status code; drives a specialized serializer for this shape
response: { 201: UserResponse },
},
}, async (request, reply) => {
// A missing email never reaches this point; the client gets:
// 400 { "statusCode": 400, "error": "Bad Request", "message": "body must have required property 'email'" }
const limit: number = request.query.limit; // typed and coerced
request.log.info({ limit, org: request.params.orgId }, 'creating user');
const user = { id: 'usr_123', email: request.body.email, passwordHash: '$2b$10$placeholderhash' };
// passwordHash is not in UserResponse, so the serializer drops it.
// A new field added here without updating UserResponse is also dropped, silently.
return reply.code(201).send(user);
});
// Schemas are compiled as routes are registered, so a malformed schema fails at startup
app.listen({ port: 3000 });
A Fastify route using TypeBox schemas for params, querystring, headers, body and a 201 response. The schemas drive Ajv validation, type coercion, handler typing and serialization that strips undeclared fields such as passwordHash.
Building the equivalent validation and error format in Express
In Express, the same route becomes a stack of middleware. A validation middleware runs first: either a JSON Schema compiled once with Ajv at startup, or a schema-library parser such as Zod. On failure it calls next() with an error object that carries a status code and the list of issues. The handler only runs if the body passed. A four-argument error middleware, registered after all routes, turns every error into one response body, for example statusCode, error and message fields. Express treats any middleware with that arity as an error handler, so this single function becomes the only place where your error contract lives.
Response shaping is your job. Nothing removes fields that aren't in a schema, so a handler that returns a database row as-is can leak columns. You need explicit mapping functions or a second validation pass on output. API documentation is a third artifact: OpenAPI is either written by hand or generated from annotations. That leaves three things to keep in sync (request validation, response mapping and docs), and drift between them shows up only when a client breaks.
This flexibility is why Express prototypes move fast, because there are no conventions to learn. The same freedom lets five services end up with three validators and four error shapes. Teams that avoid this usually publish an internal package that bundles a validator, an error class and the error middleware, and they treat its use as a requirement.
| Feature | Fastify | Express |
|---|---|---|
| Validation | Built in, JSON Schema per route | Add your own (Ajv, Zod, etc.) as middleware |
| Serialization | Schema-driven, removes undeclared fields | Plain JSON.stringify, manual shaping |
| Scoping | Plugin encapsulation | Routers and mount paths, with shared app state |
| Error handling | Default error handler with consistent format, customizable per scope | Hand-written error middleware |
| Async errors | Rejected promises handled natively | Depends on version; older releases need wrappers |
| Ecosystem size | Smaller, focused plugin set | Very large middleware ecosystem |
| Learning curve | Hooks, plugins and schemas to learn | Minimal to start, conventions are on you |
const express = require('express');
const Ajv = require('ajv');
const ajv = new Ajv({ allErrors: true });
// Compile once at startup, not per request
const validateCreateUser = ajv.compile({
type: 'object',
required: ['email', 'name'],
properties: { email: { type: 'string', minLength: 3 }, name: { type: 'string', minLength: 1 } },
additionalProperties: false
});
// Validation as middleware: the handler only runs if the body passed
const validateBody = (validate) => (req, res, next) => {
if (validate(req.body)) return next();
const err = new Error('body failed validation');
err.statusCode = 400;
err.issues = validate.errors;
next(err);
};
// Manual response shaping: only these fields ever leave the service
const toPublicUser = (row) => ({ id: row.id, email: row.email, name: row.name });
const app = express();
app.use(express.json());
app.post('/users', validateBody(validateCreateUser), (req, res) => {
const row = { id: 'u_123', ...req.body, password_hash: 'not-for-clients' };
res.status(201).json(toPublicUser(row)); // without the mapper, password_hash would leak
});
// Four arguments, registered last: the single place the error contract lives
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const error = statusCode === 400 ? 'Bad Request' : 'Internal Server Error';
res.status(statusCode).json({ statusCode, error, message: err.message, issues: err.issues });
});
app.listen(3000);
An Express route that uses an Ajv validator compiled at startup as middleware, maps the response by hand so undeclared fields stay out, and formats every failure in one four-argument error middleware.
Where Fastify's throughput advantage comes from and when it stops mattering
Fastify's lower per-request cost comes from doing work once at startup instead of on every request:
- Precompiled serializers. When a route declares a response schema, Fastify generates a specialised stringify function for that shape. It skips the generic type inspection that
JSON.stringifyperforms on every call. - Radix-tree routing. Routes are stored in a prefix tree, so lookup cost depends on path length rather than route count. Express walks its stack of layers and tests each one in order. That cost grows as an app accumulates routes and middleware.
- A leaner request path. Hooks are registered per encapsulated context, so a request runs only the hooks that apply to its route. A long global middleware chain runs for every request.
All of these savings are fixed per-request costs. They matter most when the handler does almost nothing.
Illustrative estimate
This uses assumed inputs, not measurements. Take a request that runs a 20 ms database query, 2 ms of business logic and 0.5 ms of serialization. Assume framework overhead is 0.1 ms for Fastify and 0.3 ms for Express. Total time is then about 22.6 ms against 22.8 ms, a gap of under 1%.
| Request component | Assumed time | Changes with framework? |
|---|---|---|
| Database query | 20 ms | No |
| Business logic | 2 ms | No |
| Serialization (small payload) | 0.5 ms | Partly, with response schemas |
| Routing and hooks/middleware | 0.1–0.3 ms | Yes |
The gap also narrows for large payloads. With big responses, the time to build the data and push the bytes over the network outweighs any stringify speedup.
If a query takes 20 ms, switching frameworks will not make it faster. Indexes, query shape and connection pooling will.
How to benchmark Fastify and Express fairly for your workload
Start by making the two applications do the same work. Each route should parse the same body, run the same validation, call the same downstream code, and return the same response shape. If the Express version lacks a JSON schema validator or logger that the Fastify version has, you are comparing configurations, not frameworks. Pin one Node.js version, run both on the same machine or instance type, and put the load generator on a separate host so it does not compete with the server for CPU.
Use payloads that look like your real traffic. A route returning {"hello":"world"} exercises routing and almost nothing else. Capture a few representative request and response bodies from production, including the large ones, and replay those.
Run a warm-up pass before measuring so the JIT has optimized the hot paths and connection pools are full. Then run several timed passes and compare them to each other to see how much variance you have. A minimal autocannon run looks like `autocannon -c 100 -d 30 100 concurrent connections for 30 seconds.
Read the latency distribution, not only requests per second. p50 tells you the typical case. p95 and p99 show the tail where garbage collection pauses and event-loop stalls appear. Check error and timeout counts as well, because a server that sheds requests can post a high throughput figure while failing users.
Microbenchmarks rarely carry over to production. Real services spend most of their time waiting on databases and other services, sit behind proxies, and run alongside other workloads that share the CPU. Benchmark your own slowest endpoints under that kind of load before you treat a framework difference as significant.
Choosing and migrating: who should use which, cost and engineering time
…as a reason to migrate. For most teams the framework choice is about structure and engineering time.
| Situation | Fastify | Express |
|---|---|---|
| New API with several teams sharing conventions | Use: schemas, encapsulated plugins and hooks enforce one shape | Avoid unless you already maintain an internal template |
| Stable service, no latency or CPU problem | Avoid: a rewrite buys little | Use |
| Depends on Express-only middleware | Avoid until replacements exist | Use |
| Hiring and onboarding speed matter most | Acceptable, with a short learning curve | Use: more engineers already know it |
Other Node.js API frameworks generally fall somewhere between these two: some are minimal routers, others are opinionated, decorator-driven frameworks. Judge them on the same axes.
Migrating
Fastify's Express compatibility plugin (@fastify/express) lets existing middleware run inside Fastify. This is useful as a bridge but costly as a destination. Requests pass through an extra middleware layer. That middleware runs outside Fastify's typed hooks and encapsulation, so it becomes harder to reason about which code touches a request and in what order.
Prefer route-by-route migration when the service has clear route boundaries and good integration tests. Put both apps behind one proxy and move endpoints over one at a time.
Illustrative estimate (assumed numbers, not a benchmark): Net days saved = (validation, serialization and error code avoided per year) − (middleware rewrite + retesting + retraining).
Assume 15 days saved per year, against 6 + 5 + 3 = 14 days of one-time cost. That gives roughly break-even in year one and a net gain after that. If your custom middleware is extensive, the payback period stretches.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to fastify vs express, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.