What Fastify and Express are, and why the choice is architectural
Express is the long-established default for HTTP servers in Node.js. Its project describes it as minimal and unopinionated. It gives you routing, request and response helpers, and a middleware stack, and leaves everything else to you. That small core is why teams pick it. Almost every Node.js developer has used it, most third-party integrations ship an Express middleware, and you can add or remove features with one app.use() call.
Fastify is a newer framework that bundles more decisions into its core. Each route can declare JSON Schemas for its inputs and outputs. Those schemas drive validation and response serialization. Functionality is added through plugins, and each plugin runs in its own encapsulated scope. Logging is built in instead of bolted on. Teams pick Fastify when they want those conventions enforced by the framework rather than by code review.
Most comparisons focus on requests per second, but the more important difference is how each one structures an application:
- Express runs every request through one linear chain of functions. Each function can read or change shared state, then pass control to the next.
- Fastify builds a tree of plugins. Each plugin can only see what it registered or inherited from its parents, and routes describe their contracts as data.
These models lead to different defaults for validation, error handling, testing and how a codebase grows over time, and those differences last longer than benchmark gaps. Other Node.js frameworks generally either wrap one of these two models or add a heavier layer on top, such as dependency injection and decorators. Knowing these two models first makes those alternatives easier to judge.
How the request lifecycle works in each framework
Express treats every request as a walk down one ordered list. Each app.use() or route registration appends a (req, res, next) function, and the request visits them in the order they were registered until one sends a response or passes an error to next(err). That design is simple, but nothing labels a function as parsing, auth or validation. Its role is implied only by its position in the list.
Position therefore carries meaning, and misplacing a line fails quietly:
- Routes registered before the auth middleware are unprotected. No error appears. The route simply responds first.
- A body parser such as
express.json()mounted after a router leavesreq.bodyundefined for that router's handlers. Validation code then often treats the missing body as an empty object. - Error-handling middleware with four arguments must come last. If you register it earlier, errors thrown further down the chain never reach it.
Fastify assigns each concern to a named lifecycle stage:
onRequestruns before the body is parsed, so it suits cheap rejection such as auth tokens and rate limits.preParsingcan transform the raw payload stream.preValidationruns after parsing and before schema validation.preHandlerruns on validated input, which makes it the right place for authorization that depends on the body.preSerializationcan reshape the returned object. It is skipped for strings, Buffers and streams.onSendsees the serialized payload.onResponsefires after the response is sent, which makes it the natural place for metrics.
Because the framework fixes the stage order, registering a hook in a different place in your code cannot move it ahead of body parsing.
const express = require('express');
const Fastify = require('fastify');
// --- Express: a function's role is implied only by its position ---
const app = express();
const requireAuth = (req, res, next) =>
req.get('authorization') === 'Bearer example-token' ? next() : res.status(401).end();
app.get('/admin', (req, res) => res.send('secret')); // BUG: registered before auth, so never checked
app.use(requireAuth); // protects only what is registered after this line
app.use(express.json()); // must come before any route that reads req.body
app.post('/orders', (req, res) => res.json({ id: req.body.id }));
app.use((err, req, res, next) => res.status(500).json({ error: err.message })); // 4 args, registered last
// --- Fastify: a function's role is the named stage it is attached to ---
const fastify = Fastify({ logger: true });
fastify.addHook('onResponse', async (request, reply) => {
// Runs after the response is sent, so it is a safe place for metrics
request.log.info({ status: reply.statusCode }, 'request finished');
});
fastify.post('/orders', {
schema: {
body: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } },
},
onRequest: async (request, reply) => {
// Runs before the body is parsed: rejects bad tokens cheaply
if (request.headers.authorization !== 'Bearer example-token') return reply.code(401).send();
},
preHandler: async (request, reply) => {
// Body is parsed and schema-validated here, so authorization can depend on it
if (request.body.id.startsWith('locked-')) return reply.code(403).send();
},
}, async (request) => ({ id: request.body.id })); // returned object goes through serialization
app.listen(3000);
fastify.listen({ port: 3001 });
The same auth, parsing, validation and metrics concerns in both frameworks: in Express each concern's effect depends on its registration order, and the misplaced auth line leaves /admin open, while in Fastify each concern is attached to a named lifecycle stage.
Fastify schema validation and response serialization vs Express validation middleware
Validation is one of those fixed stages. In Fastify, each route can declare a schema option with JSON Schema for body, querystring, params and headers. Fastify compiles these schemas when the route is registered and checks every request before the handler runs. A request that fails gets a 400 response with a consistent error shape, so the handler can assume its inputs are valid.
Response schemas, keyed by status code, work in the other direction. Fastify uses them to build a specialised serializer, which is faster than generic JSON.stringify. They also act as an allowlist: properties not declared in the schema are dropped. This prevents accidental leaks, such as a password hash on a user record. It also causes the classic "missing field" bug. Someone adds a property to the handler's return value, forgets to add it to the schema, and the field silently never reaches the client.
In TypeScript, a hand-written interface and a JSON Schema describe the same shape twice, and the two drift apart over time. Type providers solve this by inferring request and reply types directly from the schema, so the schema becomes the single source of truth. You can register shared schemas once and reference them with $ref, and the same definitions can generate OpenAPI documentation through @fastify/swagger.
Express has no built-in equivalent. The team chooses a validation library and adds it as middleware. That library's conventions then decide the error format, whether parsed values replace req.body, and how types are derived. Response filtering is left to your own code.
import Fastify from 'fastify';
import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
// Request and reply types are inferred from the schemas below, so no separate interface can drift.
const app = Fastify().withTypeProvider<JsonSchemaToTsProvider>();
const userReply = {
type: 'object',
properties: { id: { type: 'string' }, email: { type: 'string' } },
required: ['id', 'email'],
} as const; // 'as const' is required for type inference
app.patch('/users/:id', {
schema: {
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
querystring: { type: 'object', properties: { notify: { type: 'boolean', default: false } } },
headers: { type: 'object', properties: { 'x-request-id': { type: 'string' } }, required: ['x-request-id'] },
body: {
type: 'object',
properties: { email: { type: 'string' } },
required: ['email'],
additionalProperties: false,
},
// Compiled into a dedicated serializer that also acts as a field allowlist
response: { 200: userReply },
},
}, async (request) => {
// Invalid input never reaches this point: Fastify has already replied with a 400
const user = {
id: request.params.id, // typed as string
email: request.body.email, // typed as string
passwordHash: 'placeholder-hash', // dropped by the response schema, as intended
createdAt: new Date().toISOString(), // also dropped: the 'missing field' bug
};
return user;
});
app.listen({ port: 3000 });
A Fastify route that validates params, querystring, headers and body before the handler runs, infers TypeScript types from those schemas through a type provider, and uses a response schema that strips both the password hash and a newly added field that was never declared.
Plugins vs Express middleware: encapsulation, errors and logging
Registration and encapsulation
In Fastify, nearly everything is a plugin. You add one with register(), and Fastify loads plugins asynchronously in the order you registered them. A plugin can wait for a database connection before the routes that depend on it are added. Each register() call creates an encapsulated child context. Decorators, hooks and nested plugins added inside it are visible to that plugin and its descendants, but not to its parent or siblings. This isolation is deliberate: two route groups can use different auth hooks without affecting each other.
It also causes the most common Fastify bug. Suppose you write a plugin that decorates the instance with a db client and register it next to your route plugins. The routes then fail because fastify.db is undefined. The decorator stayed inside its own context. To expose it to the parent and its other children, wrap the plugin with fastify-plugin.
Express has no equivalent boundary. Middleware runs according to mount path and registration order, at the app level with app.use() or the Router level with router.use(). Shared state is usually attached to req or app.locals. It's simpler to reason about, but any middleware can change the request for everything that runs after it.
Errors
Express centralizes error handling in middleware with four parameters, (err, req, res, next). In Express 4, a rejected promise from an async handler is not passed to that middleware. You need to catch it and call next(err) yourself. Express 5 changed this. Before you rely on the new behavior, check the official Express 5 migration notes on expressjs.com. Fastify routes rejected promises from async handlers to the handler you set with setErrorHandler(), and that handler follows the same encapsulation rules as everything else.
Logging
Fastify includes Pino. When you enable the logger, Fastify assigns each request an ID and gives you request.log, a child logger that tags every line with that ID. Express ships without a logger, so the team picks a logging middleware and sets up request correlation itself.
const Fastify = require('fastify');
const fp = require('fastify-plugin');
// Built-in Pino logger; each request gets a reqId on request.log
const app = Fastify({ logger: true });
// Root-level error handler; child contexts inherit it unless they set their own
app.setErrorHandler((error, request, reply) => {
request.log.error({ err: error }, 'request failed');
reply.status(error.statusCode ?? 500).send({ error: 'Internal Server Error' });
});
// Wrapped with fastify-plugin: the decorator is added to the parent context,
// so the sibling route plugin below can see fastify.db
app.register(fp(async (fastify) => {
const db = { query: async (sql) => [{ id: 1 }] }; // placeholder client
fastify.decorate('db', db);
}));
// NOT wrapped: this decorator stays inside its own encapsulated context
app.register(async (fastify) => {
fastify.decorate('cache', new Map());
});
app.register(async (routes) => {
routes.get('/users', async (request) => {
request.log.info('listing users');
return routes.db.query('SELECT id FROM users'); // works because of fp()
// routes.cache is undefined here: it was encapsulated in a sibling
});
routes.get('/fail', async () => {
// A rejected promise from an async handler goes to setErrorHandler
throw new Error('database unavailable');
});
});
app.listen({ port: 3000 });
This Fastify app shows that a decorator wrapped in fastify-plugin is visible to sibling plugins and an unwrapped one is not, and that errors thrown in async handlers reach setErrorHandler and are logged with the request-scoped Pino logger.
Fastify vs Express side by side, including honest performance
Taken together, the differences look like this:
| Dimension | Fastify | Express |
|---|---|---|
| Lifecycle model | Named hooks (onRequest, preHandler, onSend, …) around each route |
Linear middleware chain driven by next() |
| Validation | JSON Schema on the route, compiled at startup | Brought in as middleware or done in handlers |
| Serialization | Response schemas compile a serializer and strip undeclared fields | res.json() calls JSON.stringify |
| Scoping | Plugins are encapsulated unless explicitly exposed | Scoped by mount path or router; app-level middleware applies globally |
| Error handling | setErrorHandler per scope; async errors are caught |
Four-argument error middleware; Express 5 forwards rejected promises |
| Logging | Built-in, per-request child logger | Pick and wire your own |
| TypeScript | Types ship with the package | Community-maintained @types/express |
| Ecosystem size | Smaller, many plugins under @fastify/* |
Large, long-established middleware catalogue |
| Express-middleware compatibility | Possible through an adapter plugin, with caveats | Native |
| Hiring familiarity | Fewer engineers have used it | Most Node.js engineers have used it |
When the performance gap matters
Fastify's speed advantage comes from per-request work it removes: route lookup, compiled validation, and schema-driven serialization. That saving is roughly fixed for each request. It matters when the handler does little else, such as health checks, cache hits, auth gateways, or thin proxies running at high request rates. In those cases framework overhead is a large share of CPU time per request.
Most endpoints wait on a database, another service, or disk. When a handler spends most of its time on I/O, a fixed saving of microseconds per request barely changes latency or the number of instances you need. Query plans and connection pooling will usually move the result more.
Don't rely on published benchmarks. Port two or three representative routes, load-test them with production-shaped payloads and real dependencies, and compare p99 latency and CPU per request.
When to choose Fastify, when to choose Express, and when to avoid each
Choose Fastify when the team will own the contract
Fastify pays off for teams that already treat request and response shapes as a contract: public APIs, services consumed by several clients, or platforms that generate documentation and client types from schemas. Schemas, encapsulated plugins, and the built-in logger give these teams structure they would otherwise have to assemble and enforce themselves.
Avoid Fastify if nobody will maintain the schemas. Schemas that are loose, stale, or copied between routes give you the overhead of the abstraction without the safety. Response schemas that drift from reality can also strip fields silently. In that case Fastify adds ceremony and little else.
Choose Express when familiarity and ecosystem matter more than structure
Express suits teams that depend on its large middleware ecosystem, hire engineers who already know it, or run many small internal services where onboarding speed matters most. It is also a poor fit in one specific situation: organizations that need consistency across services but have no strong internal conventions. Express is unopinionated, so each team will choose different validation, error handling, and logging. If consistency is the goal, either write the conventions down or let Fastify's structure supply some of them.
Don't migrate for throughput alone
If a service is I/O-bound, meaning its latency is dominated by the database or downstream calls, a framework swap rarely changes p99 enough to justify the rewrite. Migrate for maintainability, not raw speed.
Whichever framework you choose, pin to a supported major version and read that version's documentation. Check Fastify's Long Term Support policy, linked from the Fastify docs, and the version-specific guides on the Express site.
Impact on development cost and an incremental migration path
Where the cost goes
Fastify asks for more up front. Writing JSON Schemas for every route takes time, and the encapsulation model trips up engineers who expect Express's single global middleware stack. A decorator registered inside a plugin is not visible to sibling plugins unless you wrap it with fastify-plugin. The payback comes from conventions you stop rebuilding. Validation, error shapes and structured logging come with the framework, so each new service doesn't need its own versions of them.
Illustrative estimate (assumed numbers, not a benchmark): net saving = (services × hours of convention work saved per service) − (engineers × ramp-up hours) − (routes × schema hours per route). Assume 8 services × 16 h saved, 6 engineers × 10 h ramp-up, and 120 routes × 0.25 h each. That gives 128 − 60 − 30 = 38 hours saved. With two services, the same inputs give a net loss.
Migrating incrementally
- Run both apps behind a reverse proxy. Route path prefixes to Fastify one group at a time. Rollback is then a proxy config change.
- Port route groups as Fastify plugins. Register each with a prefix, so the module boundary matches the encapsulation boundary.
- Add schemas during the port. You are already rereading each handler, so this is the cheapest time to write down its contract.
- Use the compatibility layer sparingly.
@fastify/expresscan mount existing Express middleware, but that middleware runs early in Fastify's lifecycle, before body parsing and schema validation. Middleware that readsreq.bodywill not behave as it did in Express. Treat the layer as temporary.
Pin exact framework and plugin versions. Read the official migration guide before each major upgrade.
// server.mjs: Fastify app serving the route groups ported so far.
// The reverse proxy sends /orders/* here; other paths still go to Express.
import Fastify from 'fastify';
import expressCompat from '@fastify/express';
import { requestIdMiddleware } from './legacy/request-id.js'; // existing Express middleware
const app = Fastify({ logger: true });
// Temporary bridge for legacy middleware. It runs before body parsing and
// schema validation, so only mount middleware that never reads req.body.
await app.register(expressCompat);
app.use(requestIdMiddleware);
// One Express router becomes one plugin. The prefix keeps the module
// boundary and the encapsulation boundary the same.
async function ordersRoutes(fastify) {
fastify.post('/', {
// Write the handler's contract down while porting it.
schema: {
body: {
type: 'object',
required: ['sku', 'quantity'],
properties: {
sku: { type: 'string', minLength: 1 },
quantity: { type: 'integer', minimum: 1 }
},
additionalProperties: false
},
response: {
201: { type: 'object', properties: { id: { type: 'string' } } }
}
}
}, async (request, reply) => {
// request.body has already passed validation when this runs.
reply.code(201);
return { id: 'ord_placeholder_123' };
});
}
await app.register(ordersRoutes, { prefix: '/orders' });
await app.listen({ port: 3001, host: '0.0.0.0' });
A route group from Express ported as a prefixed Fastify plugin with a JSON Schema, plus @fastify/express used only for one legacy middleware that does not read the body, since it runs before parsing and validation.
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.