How the request pipelines differ: middleware chain vs lifecycle hooks
Express models a request as a single ordered list of functions. Each middleware receives (req, res, next) and either ends the response or calls next() to pass control to the next function. Parsing, authentication and routing are all middleware, so the chain is only as structured as the order of your app.use calls. A middleware that neither responds nor calls next() leaves the request hanging. Express does not record what each step is for.
Fastify defines named phases in a fixed order. onRequest runs first. preParsing runs before the body is read. preValidation runs after parsing and before schema validation. preHandler runs just before the route handler. preSerialization can transform an object payload before it becomes JSON. onSend sees the serialized payload. onResponse fires after the response has gone out, which makes it a natural place for timing and logging. Because each phase has a defined position, Fastify knows when the body exists and when validation has happened. It does not have to infer this from registration order.
Handlers differ in the same way. An Express handler must call res.send or res.json. A Fastify handler can simply return a value or a promise, and the resolved value becomes the reply.
Error handling follows from these designs. Express identifies error middleware by its four-argument signature (err, req, res, next). For most of Express's history, a rejected promise in an async handler was not caught, so developers wrapped handlers or called next(err) by hand. The current major version forwards rejections automatically. Fastify has always routed thrown errors and rejected promises from async handlers and hooks to the handler registered with setErrorHandler. If no handler is registered, Fastify sends a default JSON error response that uses the error's statusCode.
const express = require('express');
const fastify = require('fastify')({ logger: false });
// Express: one ordered chain; each step must respond or call next().
const app = express();
app.use(express.json()); // parsing is just middleware
app.use((req, res, next) => { // auth is just middleware
if (!req.get('x-api-key')) return res.status(401).json({ error: 'no key' });
next();
});
app.get('/users/:id', async (req, res) => {
// Express 5 forwards this rejection to error middleware.
// Express 4 needed try/catch plus next(err) or a wrapper.
if (req.params.id === '0') throw new Error('not found');
res.json({ id: req.params.id }); // must send explicitly
});
// Error middleware is identified by its four-argument signature.
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
app.listen(3000);
// Fastify: named lifecycle phases with fixed positions.
fastify.addHook('onRequest', async (request, reply) => {
if (!request.headers['x-api-key']) {
return reply.code(401).send({ error: 'no key' });
}
});
fastify.addHook('onResponse', async (request, reply) => {
console.log(request.method, request.url, reply.statusCode); // after reply is sent
});
fastify.get('/users/:id', async (request) => {
if (request.params.id === '0') throw new Error('not found'); // routed to setErrorHandler
return { id: request.params.id }; // returned value becomes the reply
});
fastify.setErrorHandler((error, request, reply) => {
reply.code(error.statusCode ?? 500).send({ error: error.message });
});
fastify.listen({ port: 3001 });
The same authenticated route in Express (linear middleware chain, explicit res.json, four-argument error middleware) and Fastify (onRequest/onResponse hooks, a handler that returns its result, and setErrorHandler catching a rejected async handler).
Plugins and encapsulation in Fastify compared with app.use and Router
Every call to fastify.register() creates a child context: a new instance that inherits from its parent but cannot write back into it. Decorators added with decorate, decorateRequest or decorateReply, and hooks added with addHook, are visible inside that plugin and its descendants only. The parent and sibling plugins never see them. This is deliberate. An auth hook registered inside an /admin plugin cannot accidentally run on public routes.
Shared infrastructure such as a database client has to be visible everywhere, so you wrap that plugin with fastify-plugin. The wrapper tells Fastify to skip creating a new context. The plugin's decorators and hooks then attach to the instance that registered it. You make this choice explicitly, per plugin.
Express has no equivalent boundary. app.use() applies to every route defined after it. A Router limits middleware to a mount path, but anything attached to req or app.locals remains globally reachable. Isolation depends on convention, not on the framework.
Most early Fastify confusion comes from three mistakes:
- A decorator that "doesn't exist" in a sibling plugin. It was added in an encapsulated context. Move it into a
fastify-plugin-wrapped plugin or into a common parent. - Registration order. A plugin that uses a decorator must be registered after the plugin that provides it.
- Forgetting
fastify-pluginon a shared utility, which silently scopes it to one branch.
Learning these rules takes time. In return, a large codebase becomes a tree of independently testable units whose dependencies are visible in the registration graph.
const Fastify = require('fastify')
const fp = require('fastify-plugin')
const app = Fastify()
// Shared utility: fastify-plugin skips the child context, so `db` attaches to the root
app.register(fp(async function dbPlugin (instance) {
const db = { query: async (sql) => [{ ok: 1 }] } // placeholder for a real client
instance.decorate('db', db)
instance.addHook('onClose', async () => { /* close the pool here */ })
}))
// Encapsulated: this decorator and hook exist only inside /admin and its descendants
app.register(async function adminPlugin (admin) {
admin.decorate('adminOnly', true)
admin.addHook('onRequest', async (request, reply) => {
if (request.headers['x-admin-token'] !== 'example-admin-token') {
reply.code(401).send({ error: 'unauthorized' })
return reply
}
})
// `db` is visible because dbPlugin was registered earlier on the parent
admin.get('/stats', async () => admin.db.query('SELECT 1'))
}, { prefix: '/admin' })
// Sibling: inherits `db` from the root, never sees `adminOnly` or the auth hook
app.register(async function publicPlugin (pub) {
pub.get('/health', async () => ({
db: pub.hasDecorator('db'), // true
adminOnly: pub.hasDecorator('adminOnly') // false
}))
})
app.listen({ port: 3000 })
A fastify-plugin-wrapped database decorator is shared across the whole app, while the admin plugin's decorator and auth hook stay scoped to its own child context and are invisible to the sibling public plugin.
Schema-first routes: JSON schema validation and response serialization
A Fastify route can declare body, querystring, params and headers schemas next to its handler. Ajv compiles each one into a validation function when the route is registered, not on every request, so the cost of a check is a call to generated code rather than interpretation of the schema. This replaces the Express pattern of validation middleware, where each team picks a library, writes glue code and remembers to mount it on every route. A request that fails validation never reaches the handler. Fastify replies with a 400 whose JSON body contains statusCode, error: "Bad Request" and a message such as body must have required property 'email'.
Response schemas, keyed by status code, are compiled into a dedicated serializer built on fast-json-stringify. Because the serializer knows the output shape in advance, it skips the generic work JSON.stringify does. It also emits only the declared properties, so a passwordHash attached to a user object does not leak into the response.
That filtering has costs:
- Silent drops. A new field the handler adds but nobody declares disappears without an error.
- Schema drift. The schema and the handler's actual return value diverge over time, and nothing fails at runtime.
- Changed input. Ajv options such as
coerceTypes,removeAdditionalanduseDefaultsrewrite incoming data before the handler sees it. Check which ones your Fastify version enables, and set each one deliberately.
Type providers such as TypeBox address drift on the TypeScript side. You write one schema, and both the runtime validator and the handler's types come from it. In Express, you type Request<Params, ResBody, ReqBody, Query> through generics or casts, and nothing at runtime checks that those types are true.
import Fastify from 'fastify'
import { Type } from '@sinclair/typebox'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
// Set Ajv options explicitly instead of relying on version defaults.
const app = Fastify({
ajv: { customOptions: { coerceTypes: 'array', useDefaults: true, removeAdditional: false } },
}).withTypeProvider<TypeBoxTypeProvider>()
const Params = Type.Object({ orgId: Type.String() })
const Query = Type.Object({ notify: Type.Boolean({ default: false }) }) // "true" is coerced to true
const Headers = Type.Object({ 'x-request-id': Type.String() })
// additionalProperties: false rejects unknown body fields with a 400 instead of stripping them.
const Body = Type.Object({ email: Type.String(), name: Type.String() }, { additionalProperties: false })
// Only these properties are serialized; anything else on the returned object is dropped.
const UserOut = Type.Object({ id: Type.String(), email: Type.String(), name: Type.String() })
app.post('/orgs/:orgId/users', {
schema: { params: Params, querystring: Query, headers: Headers, body: Body, response: { 201: UserOut } },
}, async (request, reply) => {
// request.body, params and query are typed from the same schemas Ajv validates.
const { email, name } = request.body
const user = {
id: 'usr_123',
email,
name,
orgId: request.params.orgId,
passwordHash: '$2b$10$placeholderplaceholderplace', // never reaches the client
}
reply.code(201)
return user // serialized as { id, email, name }: orgId and passwordHash are dropped
})
// A POST without "email" never reaches the handler and gets:
// 400 {"statusCode":400,"error":"Bad Request","message":"body must have required property 'email'"}
// (recent Fastify versions also include a "code" field)
app.listen({ port: 3000 })
A TypeBox-typed Fastify route where one set of schemas validates params, querystring, headers and body, sets explicit Ajv options, and drives a response serializer that drops the undeclared orgId and passwordHash fields.
Where API performance differences come from and when they stop mattering
Fastify's lower overhead comes from three places. Routing uses find-my-way, a radix tree that resolves a path by walking shared prefixes, while Express checks each layer in its stack against a compiled regular expression until one matches. Validation and serialization are compiled ahead of time: the request schema becomes a dedicated validator function, and the response schema drives fast-json-stringify, which emits only the declared fields without the generic type inspection JSON.stringify performs. The hook-based lifecycle also creates fewer closures and intermediate objects per request than a long middleware chain, so the garbage collector has less to clean up.
These costs are fixed per request. A hello-world route has almost no other work, so the framework's share of each request looks large. In a real API, the handler waits on a database or a downstream service, and that wait grows while the framework's cost stays the same. Tail latency follows the same pattern: p99 is usually set by slow queries, connection-pool contention or GC pauses under load, not by route matching.
Illustrative estimate. All inputs are assumed, not measured. Framework overhead is 0.1 ms per request for Fastify and 0.3 ms for Express.
| Handler work (assumed) | Fastify overhead share | Express overhead share |
|---|---|---|
| 0 ms (hello world) | ~100% | ~100% |
| 1 ms (cache read) | ~9% | ~23% |
| 15 ms (DB query) | ~0.7% | ~2% |
| 80 ms (downstream API call) | ~0.1% | ~0.4% |
In the 15 ms case, switching frameworks would save about 0.2 ms of a 15.3 ms request.
How to benchmark Fastify vs Express honestly
Start with parity. Each Fastify route that declares a request and response schema needs an Express counterpart doing the same work: validating the same body with the same rules, stripping the same fields, and serializing the same shape. If the Express side skips validation, you are measuring missing work, not framework overhead. Use the same Node.js version, the same logger configuration, and the same process count for both.
Then load the routes your service actually serves, including the slow ones. A hello-world endpoint only shows the framework-bound row of the table above. A representative POST might look like `autocannon -c 100 -d 30 -m POST -H "content-type: application/json" -b '{"sku":"A1","qty":2}' repeated for each hot endpoint in rough proportion to production traffic.
Record p50, p99 and max latency alongside requests per second. Two frameworks can have similar throughput while one has a much worse tail under load, and the tail is what callers notice. Treat every number as valid only for your own code, payloads and hardware.
Pitfalls checklist
- Load generator on the same host. It competes with the server for CPU. Run it on a separate machine.
- No warm-up. Discard the first run so JIT compilation and connection setup don't skew the results.
- Development settings. Set
NODE_ENV=production, and make sure pretty-printed logs are either off or identical on both sides. - Ignoring errors. Check autocannon's non-2xx and error counts. Fast 400s inflate throughput.
- Single runs. Repeat each test several times and report the spread across runs.
- Mismatched pipelining or connection counts between the two test runs.
#!/usr/bin/env bash
# Run from a separate load-generator machine, not the server host.
# Both servers: same Node.js version, NODE_ENV=production, same logger config,
# same process count. Benchmark one framework at a time on the same hardware.
set -euo pipefail
TARGETS=("fastify=" "express=")
BODY='{"sku":"A1","qty":2}'
RUNS=5
# Identical connection and pipelining settings for both frameworks.
OPTS=(-c 100 -p 1 -m POST -H "content-type: application/json" -b "$BODY")
for target in "${TARGETS[@]}"; do
name=${target%%=*}; url=${target#*=}/orders
# Warm-up run: output discarded so JIT and connection setup don't skew results.
npx autocannon "${OPTS[@]}" -d 10 "$url" > /dev/null 2>&1
for i in $(seq 1 "$RUNS"); do
# -j prints the result as JSON so it can be parsed and compared across runs.
npx autocannon "${OPTS[@]}" -d 30 -j "$url" > "${name}-run${i}.json"
# Record tail latency, throughput, and failures. A high non2xx count
# (for example fast 400s) means the run is not a valid comparison.
jq -r --arg n "$name" --arg i "$i" \
'"\($n) run \($i): rps=\(.requests.average) p50=\(.latency.p50)ms p99=\(.latency.p99)ms max=\(.latency.max)ms non2xx=\(.non2xx) errors=\(.errors)"' \
"${name}-run${i}.json"
done
done
# Report the spread across runs, not one number. Repeat for each hot endpoint
# in rough proportion to production traffic.
A bash script that warms up each server, runs autocannon several times against the same POST route with identical settings, and records requests per second, p50/p99/max latency, and non-2xx and error counts for each run.
Migrating from Express route by route
The compatibility layers are the obvious starting point. @fastify/express gives you a full Express-style app.use, and @fastify/middie runs Connect-style middleware. Both work, but treat them as scaffolding. Mounted middleware runs outside Fastify's hook system, so it gets no encapsulation and no schema-aware lifecycle, and it adds per-request overhead. Leave everything behind them and you have paid for a migration without collecting its benefits.
The subtler breakage comes from object semantics. Middleware receives Node's raw req and res, not Fastify's request and reply. Code that calls res.json(), reads req.body before parsing has run, or writes to res directly will either fail or bypass serialization and later hooks. The same applies to reply.raw inside handlers. Teams that keep both models in one codebase tend to ship handlers where it is unclear which object a helper expects, and those bugs only show up at runtime.
A safer path:
- Move one route at a time. Give it request and response schemas as you go, and route traffic to it behind a prefix or proxy rule.
- Once no Express routes depend on a piece of middleware, rewrite it natively.
For example, an Express auth middleware that checks a header and calls next(err) becomes a preHandler hook. The hook verifies the token and assigns request.user, which you declare once with decorateRequest. On failure it returns reply.code(401).send(...). Wrap the hook in a plugin. If every route needs it, use fastify-plugin. If only one route group needs it, register it inside that group's scope.
Who should choose which: use cases, alternatives and impact on engineering cost
Choose on engineering cost, not benchmark speed. Section 4 showed that throughput gaps usually disappear behind database and network latency, so the deciding factor is how much structure your team wants enforced.
Fastify pays off when several teams run many services and API contracts already matter. One JSON schema per route validates input, serializes output and can drive generated types, so validation, documentation and client code come from the same source. Response schemas also drop undeclared fields. That prevents a common class of data-leak bug, where an internal column or a password hash ends up in a payload because someone returned a raw database row. These gains depend on commitment: Fastify with schema-less routes and ad hoc decorators is just Express with more concepts to learn.
Express remains a reasonable default. Stay on it when the app depends on Express-specific middleware that has no plugin equivalent, or when the team is small and ships few services. Also stay if familiarity matters more than structure. Nearly every Node.js engineer has used Express, which shortens hiring and onboarding. Other Node.js frameworks exist, but for most teams Express is the real alternative.
| Factor | Fastify | Express |
|---|---|---|
| Ecosystem breadth | Smaller, plugin-based | Very large middleware ecosystem |
| Onboarding | Encapsulation and hooks to learn | Familiar to most hires |
| Validation and types | Built into routes via schemas | Added and maintained by hand |
| Data-leak protection | Response schemas filter output | Depends on discipline |
| Best fit | Many services, contract-driven teams | Small teams, middleware-heavy apps |
To decide, estimate the monthly hours your team spends writing validation, keeping types in sync and fixing over-exposed fields. Compare that with the one-time cost of migration and training, plus the extra onboarding time for each new hire.
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.