Express to Fastify migration without breaking middleware
Planning an Express to Fastify migration? See what ports over, what needs refactoring, and how to measure latency and throughput before you rewrite.

Why this migration feels simple until it is not
At first glance, Express and Fastify look close enough that a team expects a quick swap. Both handle routes, requests, replies, and plugins. That surface match is why an Express to Fastify migration often starts with too much confidence.
The trouble shows up when you look at how the app actually works day to day. In many Express codebases, middleware is not a small detail. It shapes the whole app. Auth, logging, body parsing, validation, error handling, request context, and even business rules often live in one long chain of app.use() calls.
That habit does not move over cleanly. Fastify has hooks and plugins, and they are not just different names for the same thing. Fastify wants clearer boundaries. It prefers route schemas, encapsulated plugins, and explicit lifecycle steps. If your Express app depends on middleware reaching into req and changing it on the fly, the rewrite gets messy fast.
This is where teams burn time. They assume the framework is the slow part, rewrite a large chunk, and only then learn that the database, a slow auth provider, or heavy response shaping caused most of the delay. The new code may look cleaner and still feel no faster to users.
A small test tells you more than a full rewrite. Pick one busy route with simple business logic. Build the same route in Fastify. Keep the same database call, same payload, and same infra. Then compare latency, throughput, error rate, and developer effort. If the route gets faster by a clear margin and the code stays easy to maintain, you have a reason to keep going.
That cautious approach is not glamorous, but it saves weeks. Oleg Sotnikov has done this kind of architecture work at production scale, and the pattern is usually the same: measure first, move one slice, then decide. Teams that do that avoid a costly rewrite built on guesswork.
What usually moves over cleanly
A lot of the code you care about most does not need a full rewrite. In a typical Express to Fastify migration, the safest parts are the parts that already live away from req and res.
Route handlers often move with only small edits. If a handler already takes input, calls a service, and returns JSON, you usually just swap Express response calls for Fastify reply methods and adjust a few request property names. The business logic inside the handler can stay the same.
Validation also tends to survive the move when it is not glued to Express middleware. If you keep schemas in plain modules with Zod, Joi, Yup, or custom functions, you can reuse them. You may choose to move some checks into Fastify's schema system later, but that is an improvement step, not a blocker.
Service layers are usually the easiest win. A function like createInvoice, sendResetEmail, or calculateQuote should not care which web framework called it. If your services accept plain arguments and return plain results, they can stay put.
The same goes for database code. Query builders, ORM models, repository modules, and transaction helpers rarely depend on Express itself. If a file only talks to PostgreSQL, Redis, or another API, Fastify does not change much there.
Utility code almost never needs attention. Date helpers, formatters, mappers, permission checks, retry helpers, and config loaders should move over untouched. That is a good sign. It means your app has some healthy separation already.
A simple example makes this clearer. Say you have a POST /users endpoint. The Express handler reads the body, calls userService.create, and returns the new user. In Fastify, the route file changes a bit, but userService.create, the password rules, the email helper, and the database insert can remain the same.
If most of your code sits in services, data access, and plain helper modules, the migration is usually smaller than it first looks. The pain starts where request and response objects leak into everything else.
Where old middleware habits break
Most migration pain starts when old Express code assumes every request passes through the same mutable pipeline. Fastify can do the same job, but it wants clearer boundaries and fewer side effects.
The first surprise is req and res. In Express, many teams treat them like open containers. They attach req.user, req.tenant, res.ok, or small helper methods and expect every later middleware to see them. Fastify gives you request and reply, and they do not match Express one to one. Some familiar fields exist, some move, and some patterns need decorators or hooks instead of direct mutation.
That difference gets bigger when your app relies on one long global chain. Express makes it easy to stack middleware in a single order and trust that each step tweaks the request before the route runs. Fastify prefers hooks, plugins, and route-level options. That shape is stricter, which is usually good, but old code that depends on hidden ordering breaks fast.
A common example looks harmless: auth middleware parses a token, another middleware loads an account, a third adds a custom response helper, and the route assumes all three already ran. In Fastify, you should make that flow explicit. If you keep it implicit, one route registration change can quietly remove part of the chain.
Monkey-patching causes the ugliest bugs. It feels convenient to bolt custom fields onto the request or response object, but that habit makes migrations messy and testing weaker. Fastify gives you safer ways to extend behavior:
- use decorators for shared methods or properties
- use hooks for request lifecycle steps
- use plugins to scope behavior to part of the app
- keep route handlers plain when the logic is local
Error handling also changes shape. Express often leans on next(err) and a final error middleware that catches everything. Fastify expects you to throw errors or send them through its own error handling path. If you copy Express handlers as-is, you can end up with double replies, swallowed errors, or status codes that change in odd places.
This is where many Express to Fastify migration plans slow down. If a route depends on hidden req changes, response monkey-patches, or catch-all error middleware, treat that route as a small redesign instead of a direct port.
How common Express patterns map to Fastify
Most Express apps grow around app.use(). During an Express to Fastify migration, that habit is the first thing to rethink. In Fastify, the better question is not "where do I stick this middleware?" but "when should this run, and for which routes?"
If the logic should affect a whole part of the app, register a plugin for that scope. If it should run before a handler, use a hook such as onRequest, preParsing, or preHandler. If only one route needs it, put the behavior on that route instead of making it global. That small change often removes a lot of hidden coupling.
res.locals also needs a new home. Fastify does not lean on a shared response bag in the same way. A cleaner pattern is to attach request-specific data to request, often after auth or lookup work, and read it later in the handler. For example, if a token check loads the current user, store that user on the request and keep the data tied to that single request.
Auth, logging, and CORS usually get cleaner in Fastify. Auth checks fit well in onRequest or preHandler, depending on whether you need the parsed body first. Logging should use Fastify's built-in logger so each request keeps its own context. For CORS, use the Fastify plugin instead of hand-written headers. Manual CORS code tends to break on preflight requests and odd browser cases.
Body parsing is another place where old habits linger. Express apps often stack body-parser, validation middleware, and custom error handling. Fastify already parses JSON and can validate request bodies with route schemas. If you add a schema to POST /orders, Fastify can reject bad input before your handler runs. That cuts down on boilerplate and makes route behavior easier to read.
A good rule is to sort each Express middleware by scope, timing, and purpose. Once you do that, the mapping gets clearer: some code becomes a plugin, some becomes a hook, and some disappears because Fastify already does the job.
A safe way to try one route first
Start with one route that gets a lot of traffic and does very little writing. A read-heavy endpoint gives you cleaner signals. If something goes wrong, you can usually switch back fast without dealing with duplicate writes or broken state.
Pick a route with simple inputs. A request like GET /products/:id is a better test than a route that mixes auth checks, file uploads, and database updates. You want a narrow case where you can say, with confidence, whether the Fastify version behaves the same.
Do not rewrite the business logic yet. Keep the service code, queries, and response mapping as close to the Express version as possible. In an Express to Fastify migration, the first win is parity, not cleanup. If you change the framework and the logic at the same time, you will not know which change caused the bug or the speed shift.
Build a thin Fastify wrapper around that route and match the response exactly. Keep the same status codes, JSON fields, error format, and any headers that clients depend on. Small differences matter. A mobile app or another backend may rely on a header you forgot was there.
A simple trial usually looks like this:
- choose one GET route with clear params or query values
- move the handler into Fastify without changing the service layer
- compare response body, headers, and status codes against Express
- run the same tests and add a few side-by-side checks
- release it behind a flag or a tiny traffic slice
That last step saves a lot of pain. Send 1 percent of traffic, or enable the Fastify route for internal users first. Watch errors, latency, and odd client behavior for a few days. If the route stays quiet, you have a working pattern for the next one.
This is also how experienced CTOs reduce migration risk in real systems: one route, same logic, measured rollout, then repeat.
How to prove the speed gain before a rewrite
A benchmark only helps if both apps do the same work. Put the Express route and the Fastify route on the same machine, with the same Node version, payload size, headers, database calls, and keep-alive settings. If one side skips validation or sends a smaller response, the result is useless.
Warm up both apps before you record anything. The first runs often include JIT compilation, fresh connections, and empty caches. That is startup behavior, not normal traffic. Run each test a few times and keep the middle result, not the best one.
Start with one route, not the whole API. Pick a route that matters in real use, such as GET /users/:id or POST /orders. Measure that route alone first. After that, run a mixed workload that looks like production, where reads, writes, auth checks, and logging all happen together.
Track more than requests per second:
- p50 and p95 latency
- throughput at steady concurrency
- error rate and timeouts
- CPU use and memory growth
p50 shows the normal case. p95 shows the slow requests users notice. CPU tells you something different. If Fastify gives similar latency but uses much less CPU, that still matters. You may avoid a bigger server bill, or keep more room for background jobs.
Be strict about the test setup. Do not compare a laptop run today with a cloud VM run tomorrow. Background noise from other processes can hide a real difference. Write down the concurrency level, test duration, payload fixtures, headers, and Node flags, then rerun the same plan after every change.
For an Express to Fastify migration, a modest but repeatable win is enough. If one route gets lower p95, fewer errors, and lower CPU on the same hardware, you have proof that the rewrite can pay off. If the gain only shows up in peak requests per second and disappears under a mixed workload, keep the current code and skip the rewrite.
A realistic example with a small API
Take a small Express service for a task app. It has login, a few CRUD routes for projects and tasks, request logging, cookie handling, and one file upload endpoint for avatar images. That is a common place to start an Express to Fastify migration because the app is big enough to show the rough edges, but still small enough to change in a day or two.
The easiest part to move is the business logic. If your route handlers already call a service layer like taskService.create() and your SQL lives in separate files, keep all of that as is. Only swap the web layer. In practice, many teams rewrite too much and lose time testing code that never needed to change.
A first pass often looks like this:
- keep the same database client and SQL queries
- keep the same auth checks and service functions
- rewrite only the route registration and request or reply handling
- move request logging into Fastify hooks or a logging plugin
- add plugins for cookies and multipart uploads
Logging is a good example of code you can shrink. In Express, teams often attach a logger middleware near the top, then add custom timing logic in another place. In Fastify, a hook like onRequest or onResponse can handle most of that in one pattern. Plugin setup can also clean up repeated code, because you register behavior once and use it across routes.
There is a catch. Fastify plugins add rules. You need to register them in the right order, and some features do not exist until the plugin is loaded. That feels stricter than Express. It is also easier to reason about once you accept it.
Say your old Express app does this for auth: read a cookie, verify a token, attach req.user, then continue. In Fastify, that often becomes a decorator plus a pre-handler. The auth logic stays the same, but the shape changes. The same goes for logging. A hook replaces the old habit of stacking middleware after middleware.
Do the messy checks by hand, not only with automated tests. Open the app and verify a few real requests:
- upload a file and confirm size limits and error messages
- send a bad cookie and check the auth response
- trigger a validation error and compare the JSON body
- create, update, and delete one record
That last step matters more than most teams expect. File uploads, cookies, and error responses are the spots where Express habits usually leak through. When those match, the migration is usually on solid ground. When they do not, the fix is often small, but you want to find it early.
Mistakes that waste time
Most wasted effort in an Express to Fastify migration comes from treating it like a file move. Teams copy every middleware file, rename a few imports, and expect the same request flow. Fastify can handle the same jobs, but it wants a cleaner split between hooks, plugins, decorators, and schemas. If you port everything as-is, you keep the old mess and lose much of the upside.
Another slow mistake is changing route behavior while changing frameworks. A login route that trims fields differently, a stricter validation rule, or a new error shape can turn one migration into a bug hunt. Freeze behavior first. Keep the same inputs, outputs, status codes, and auth rules until the route works and tests pass.
Schemas matter more than many Express teams expect. If you skip them, Fastify has less information for validation, typing, and serialization. Then bugs show up in odd places, and the framework gets blamed for problems the old app already had. A route with a request schema and a response schema is often easier to port than a route built on hidden middleware side effects.
Bad benchmarks waste days too. Comparing local runs with different Node versions, different logging settings, or different payloads does not prove much. Keep the test plain and fair:
- use the same Node version
- hit the same route with the same payloads
- keep database access either real in both apps or mocked in both
- disable noisy logs during the run
- warm up both servers before measuring
The biggest mistake is rewriting the whole app before one route passes tests. Pick a route with real traffic but limited business logic. Port it, test it, benchmark it, and check the diff in behavior. If that one route gets simpler and faster, keep going. If it does not, stop and fix your migration plan before you move the rest of the API.
Quick checks before you commit
A rushed rewrite usually fails for a boring reason: the team does not know what the current app actually depends on. Before you move a single route, write down every Express middleware in use and label each one: "native Fastify feature", "plugin", "custom rewrite", or "remove". That simple map will show where the real work sits.
Pay extra attention to routes that do more than return JSON. File uploads, streaming responses, webhooks, and WebSocket endpoints often look small in the codebase, but they break first when old middleware habits carry over. If your product streams AI output or large reports, test that path on its own instead of treating it like a normal request.
A short pre-migration audit saves days later:
- List each middleware and its Fastify replacement, or mark it for removal.
- Mark routes that use uploads, streams, background jobs, or sockets.
- Check tests for auth failures, bad input, missing headers, and rate limits.
- Make sure your benchmark uses real payload sizes, headers, and concurrency.
- Set a clear pass line before you start, such as lower p95 latency or lower CPU use.
Tests matter more than people expect. Many teams cover the happy path and miss the ugly cases: expired tokens, oversized bodies, malformed JSON, duplicate requests, or a client disconnect in the middle of a stream. If those cases are not covered now, the migration will create doubt even when the code is fine.
Your benchmark should also look like production, not a toy demo. Use the same keep-alive behavior, body sizes, auth headers, and mix of fast and slow routes. A "benchmark Express vs Fastify" script that hits one tiny GET endpoint 100,000 times may give a nice chart, but it tells you very little about real traffic.
Set the finish line in plain numbers. For an Express to Fastify migration, that might mean 20 percent lower p95 latency, fewer dependencies, and no regressions in auth or streaming. If you cannot say what counts as a win, wait a week and define it first.
What to do next
Do not start with a full rewrite. Pick one route that gets real traffic, has one or two middleware layers, and is easy to measure. That pilot will show your team what actually changes in an Express to Fastify migration, not what you assume will change.
Write down the differences while they are still fresh. Keep it short. A single page is enough if it answers simple questions: which middleware moved cleanly, which parts needed hooks or decorators, what broke in tests, and how much code got simpler or harder to read.
A small migration map saves a lot of repeat work later. It should cover things like auth, validation, error handling, request context, and logging. When the next route moves over, your team should not have to rediscover the same plugin choice or hook order.
A simple checklist works well:
- Move one route with real dependencies
- Note every Express pattern that needed a Fastify alternative
- Run the same benchmark before and after
- Compare latency, throughput, memory use, and error rate
- Decide if the gain is worth wider change
The benchmark matters more than the feeling. Some teams move because Fastify looks cleaner. That is not enough. If your route handles requests 20 to 30 percent faster, uses less memory, and stays easy to debug, the case gets stronger. If the gain is tiny and the rewrite cost is high, stop there and keep Express for now.
This is also the stage where outside review can save money. A good Fractional CTO can look at your plugin choices, benchmark setup, rollout risk, and team habits before you commit to a wider move. Oleg Sotnikov does this kind of work for startups and small businesses, especially when the goal is to cut waste, keep uptime steady, and avoid a rewrite that solves the wrong problem.
If the pilot route goes well, expand one slice at a time. If it does not, you still learned something useful without betting the whole API.