How to Migrate from Express to Fastify
TL;DR
Migrating from Express to Fastify gives you 2-3x better throughput and first-class TypeScript types. The route handler API is similar enough that most migrations are mechanical. Key differences: Fastify uses plugins (not middleware), request/response objects have different APIs, and you get built-in JSON schema validation. Most Express apps migrate in a day or two; complex middleware chains may take longer.
Key Takeaways
- 2-3x throughput improvement — Fastify handles ~77K req/sec vs Express's ~27K
- Plugin system replaces middleware —
fastify.register()instead ofapp.use() - Built-in JSON Schema validation — no separate validation library needed for basic cases
- TypeScript-first — Fastify generics type request body, params, query, response
fastify-expressfor gradual migration — run Express middleware inside Fastify
Before You Start: Assess Your Express Setup
Before touching a line of code, map out what your Express application is actually doing. The migration complexity is determined almost entirely by your middleware stack, not your route handlers.
Walk through your app.ts or server.ts and inventory: every app.use() call, every custom error handler, every third-party middleware package, and any patterns where middleware attaches custom properties to req. Most route handlers migrate in minutes — a complex middleware chain that threads state through multiple middleware functions takes more thought.
Make a list like this before starting:
- Global middleware: cors, helmet, body parsing, compression, morgan
- Auth middleware: passport, JWT verification, session handling
- Custom middleware: request ID generation, tenant detection, rate limiting
- Error handlers: the
(err, req, res, next)4-argument handlers - Static file serving:
express.static()
For each item, you need a Fastify equivalent. Most have direct counterparts in the @fastify/* plugin ecosystem. The few that don't can usually be bridged using @fastify/express during a gradual migration.
Step 1: Install Fastify and Create the Server
Start by installing Fastify and the plugin packages you'll need:
npm install fastify
npm install -D @types/node # If TypeScript
# Common Fastify plugins (replacing Express middleware):
npm install @fastify/cors # cors
npm install @fastify/helmet # helmet
npm install @fastify/rate-limit # express-rate-limit
npm install @fastify/jwt # jsonwebtoken wrapper
npm install @fastify/cookie # cookie-parser
npm install @fastify/static # express.static
npm install @fastify/multipart # multer (file uploads)
npm install @fastify/sensible # HTTP error helpers
Create the Fastify server alongside your existing Express server. Don't delete Express yet — run both on different ports during the migration:
// src/server.ts — new Fastify server
import Fastify from 'fastify';
const fastify = Fastify({
logger: true, // Built-in Pino logger (replaces Morgan)
});
// Register plugins
await fastify.register(import('@fastify/cors'), {
origin: process.env.CORS_ORIGIN ?? '*',
credentials: true,
});
await fastify.register(import('@fastify/helmet'));
await fastify.register(import('@fastify/sensible'));
// Start server
await fastify.listen({ port: 3001, host: '0.0.0.0' });
console.log('Fastify listening on port 3001');
Compare the Express app creation to Fastify's:
// Express (before):
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
const app = express();
app.use(express.json());
app.use(cors());
app.use(helmet());
app.listen(3000, () => console.log('Express listening on port 3000'));
// Fastify (after):
import Fastify from 'fastify';
const fastify = Fastify({ logger: true }); // JSON body parsing is built-in
await fastify.register(import('@fastify/cors'));
await fastify.register(import('@fastify/helmet'));
await fastify.listen({ port: 3000, host: '0.0.0.0' });
Note that express.json() has no equivalent in Fastify because Fastify parses JSON request bodies automatically. You don't need to register body parsing middleware.
Step 2: Migrate Routes
Route migration is the most mechanical part. The HTTP method names are identical (get, post, put, delete, patch). The path syntax is the same. The async handler pattern works the same. The main differences are: use request and reply instead of req and res, use reply.send() instead of res.json(), use reply.code() instead of res.status(), and you can return a value directly instead of calling res.json().
// Express route syntax:
app.get('/users/:id', async (req, res) => {
const { id } = req.params;
const user = await db.user.findById(id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
});
app.post('/users', async (req, res) => {
const { name, email } = req.body;
const user = await db.user.create({ name, email });
res.status(201).json(user);
});
// Fastify equivalent:
fastify.get('/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await db.user.findById(id);
if (!user) return reply.code(404).send({ error: 'Not found' });
return user; // Just return — Fastify auto-serializes
});
fastify.post('/users', async (request, reply) => {
const { name, email } = request.body as { name: string; email: string };
const user = await db.user.create({ name, email });
return reply.code(201).send(user);
});
The return user pattern (returning the response value directly instead of calling reply.send()) is idiomatic Fastify. The framework intercepts the return value and serializes it. This makes handler code cleaner, especially for simple GET routes.
Step 3: TypeScript Typed Routes
This is where the migration pays its biggest dividend. Express's TypeScript types are famously imprecise — req.body is typed as any, req.params is Record<string, string>, and getting proper types requires manual casting everywhere. Fastify's generic-based type system gives you precise types throughout the handler:
// Fastify generics give you full type safety:
interface CreateUserBody {
name: string;
email: string;
}
interface UserParams {
id: string;
}
interface UserResponse {
id: string;
name: string;
email: string;
createdAt: string;
}
// RouteGenericInterface — typed request and response
fastify.post<{
Body: CreateUserBody;
Reply: UserResponse;
}>('/users', async (request, reply) => {
const { name, email } = request.body;
// TypeScript knows: { name: string; email: string }
const user = await db.user.create({ name, email });
return reply.code(201).send(user);
// TypeScript validates: must match UserResponse shape
});
// Typed GET with params and query string:
fastify.get<{
Params: { id: string };
Querystring: { include?: string };
Reply: UserResponse;
}>('/users/:id', async (request, reply) => {
const { id } = request.params; // string
const { include } = request.query; // string | undefined
const user = await db.user.findById(id);
if (!user) return reply.notFound(); // @fastify/sensible
return user;
});
Step 4: Migrate Middleware to Plugins
Fastify's plugin system is the biggest conceptual shift. In Express, app.use() adds middleware that runs for all subsequent routes. In Fastify, you register plugins that encapsulate functionality and can be scoped to specific route groups.
// Express middleware pattern:
app.use(cors());
app.use(helmet());
app.use(express.json());
// Fastify plugin pattern:
const fastify = Fastify({ logger: true });
// Register global plugins before routes
await fastify.register(import('@fastify/cors'), {
origin: process.env.CORS_ORIGIN,
credentials: true,
});
await fastify.register(import('@fastify/helmet'));
await fastify.register(import('@fastify/sensible'));
// Route-scoped plugins (only applies to routes in this scope)
await fastify.register(async (scope) => {
await scope.register(import('@fastify/jwt'), {
secret: process.env.JWT_SECRET!,
});
// These routes require JWT auth:
scope.addHook('preHandler', scope.authenticate);
scope.get('/me', async (request) => {
return request.user;
});
scope.get('/dashboard', async (request) => {
return { data: await getDashboardData(request.user.id) };
});
}, { prefix: '/api/v1' });
Scoped plugins are a significant improvement over Express. In Express, you might have auth middleware applied globally and then use next() skip logic for public routes. In Fastify, auth is a plugin registered only on the scope that needs it — no route can accidentally bypass it by being placed before the app.use() call.
Common Express-to-Fastify middleware mappings:
| Express Package | Fastify Equivalent |
|---|---|
cors | @fastify/cors |
helmet | @fastify/helmet |
express-rate-limit | @fastify/rate-limit |
cookie-parser | @fastify/cookie |
multer | @fastify/multipart |
express.static | @fastify/static |
morgan | Built-in Pino logger |
jsonwebtoken + middleware | @fastify/jwt |
compression | @fastify/compress |
Step 5: Hooks — Replacing Middleware Logic
Fastify's hook system replaces the common pattern of Express middleware that modifies req or performs pre-route actions. Hooks run at specific lifecycle points and have direct access to request and reply objects:
// Express: use middleware for cross-cutting concerns
app.use((req, res, next) => {
req.requestId = uuid();
next();
});
// Fastify: use hooks
fastify.addHook('onRequest', async (request, reply) => {
request.requestId = crypto.randomUUID();
});
// Fastify hook lifecycle (in execution order):
// 1. onRequest — first, before parsing
// 2. preParsing — before body parsing
// 3. preValidation — before schema validation
// 4. preHandler — before route handler (auth, guards)
// 5. handler — your route function
// 6. preSerialization — before response serialization
// 7. onSend — before sending response
// 8. onResponse — after response sent (logging, cleanup)
// 9. onError — on unhandled error
Hooks can be global (registered on the root fastify instance) or scoped (registered inside a register callback, only applying to routes in that scope). This gives you more control than Express's linear middleware chain.
Step 6: Error Handling
Express's error middleware is identified by its 4-argument signature (err, req, res, next). Fastify uses a dedicated setErrorHandler method:
// Express error middleware:
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error' });
});
// Fastify error handler:
fastify.setErrorHandler(async (error, request, reply) => {
fastify.log.error(error);
// Fastify HTTPError (from @fastify/sensible)
if (error.statusCode) {
return reply.code(error.statusCode).send({ error: error.message });
}
// Validation errors (from JSON Schema)
if (error.validation) {
return reply.code(400).send({
error: 'Validation Error',
details: error.validation,
});
}
return reply.code(500).send({ error: 'Internal Server Error' });
});
// In route handlers — throw to trigger the error handler:
fastify.get('/users/:id', async (request, reply) => {
const user = await db.user.findById(request.params.id);
if (!user) throw reply.notFound('User not found'); // @fastify/sensible
return user;
});
The reply.notFound(), reply.badRequest(), and similar methods from @fastify/sensible create HTTP errors that are automatically caught by setErrorHandler. This replaces the common Express pattern of createError(404, 'Not found') from http-errors.
Step 7: JSON Schema Validation
Express has no built-in request validation — teams typically reach for Joi, Zod, or manual validation. Fastify has JSON Schema validation built into every route:
// Fastify validates request/response automatically with JSON Schema
fastify.post('/users', {
schema: {
body: {
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 50 },
email: { type: 'string', format: 'email' },
},
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
},
},
},
}, async (request, reply) => {
// request.body is validated — name and email guaranteed to exist
const user = await db.user.create(request.body);
return reply.code(201).send(user);
});
The response schema also serves as a serializer — Fastify uses it to serialize response objects faster than JSON.stringify. This is one reason Fastify is faster than Express on CPU-bound benchmarks: the response schema lets Fastify know the exact shape of the output and skip expensive generic serialization.
If you prefer Zod over JSON Schema, the fastify-type-provider-zod package integrates them cleanly:
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
fastify.setValidatorCompiler(validatorCompiler);
fastify.setSerializerCompiler(serializerCompiler);
Gradual Migration: Run Both
If your application is large, a complete rewrite at once is risky. The @fastify/express plugin lets you run Express middleware inside Fastify while you incrementally migrate routes:
// @fastify/express — run Express middleware inside Fastify
import fastifyExpress from '@fastify/express';
await fastify.register(fastifyExpress);
// Now you can use Express middleware in Fastify:
fastify.use(legacyExpressMiddleware()); // Temporary bridge
This lets you start the Fastify server, keep your Express middleware running via the bridge, and migrate routes one by one. Remove the bridge once all routes are migrated.
Performance Results
After migrating a typical Express REST API to Fastify, you can expect:
- Throughput: 2-3x improvement on CPU-bound routes (simple CRUD operations)
- I/O-heavy routes: 10-30% improvement (database calls dominate the response time)
- Latency: Reduced median and p99 latency due to faster JSON serialization
- Memory: Lower memory usage due to Fastify's lean core
The throughput gains are most visible in load tests. Express handles approximately 27K simple requests per second; Fastify handles approximately 77K on the same hardware. Real applications sit somewhere between these extremes depending on how much time is spent in business logic vs framework overhead.
Package Health
| Package | Weekly Downloads | TypeScript | Validation Built-in | Performance |
|---|---|---|---|---|
| fastify | ~4M | First-class generics | Yes (JSON Schema) | ~77K req/s |
| express | ~35M | Via @types/express | No (separate library) | ~27K req/s |
| @fastify/core | — | — | — | — |
Common Migration Pitfalls
A few issues come up repeatedly during Express-to-Fastify migrations. Being aware of them upfront saves debugging time.
Forgetting await on fastify.listen(): Express's app.listen() is synchronous. Fastify's is async. Forgetting await means your plugins may not be registered before the server starts accepting connections, leading to "route not found" errors on the first few requests.
Plugin registration order: Fastify registers plugins asynchronously and in order. A route that depends on a plugin (like @fastify/jwt) must be registered after that plugin. If you see "fastify.authenticate is not a function," check that the JWT plugin is registered before the route handler tries to use it.
request.params type casting: In Express with TypeScript, req.params is typed as Record<string, string>, and you access params with req.params.id. The same works in Fastify, but you may need to add the generic type or cast to avoid TypeScript errors: const { id } = request.params as { id: string }. Using typed route generics (fastify.get<{ Params: { id: string } }>) eliminates the cast entirely.
Reply already sent: Fastify will throw an error if you try to send a response after one has already been sent. This often happens when developers write reply.send(data); return; (double response) or forget to return after an early exit. Always use return reply.send() or just return data — never both.
Express next() doesn't exist: If you're copying Express middleware logic, remember there's no next() in Fastify hooks. To move to the next step, simply don't call anything — Fastify's lifecycle continues automatically. To abort the request with an error, throw an error or call reply.send() with an error response.
Is the Migration Worth It?
For most active projects that are growing and where TypeScript investment is ongoing, yes — but the cost-benefit depends on scale and team context. A solo developer's hobby API running on a small VPS will see negligible real-world difference between Express and Fastify. A team running a high-traffic API with load balancing, extensive TypeScript usage, and CI pipelines will see meaningful improvements on all three dimensions: throughput, type safety, and developer experience.
The TypeScript improvement alone is often cited by teams that have completed the migration as the primary win in practice. Generated response types that catch mismatches at compile time, fully typed middleware through the hook system, and IntelliSense that actually understands your request shape — these productivity gains accumulate over months of development.
If your primary motivation is performance and you have a CPU-bound API (rare in most web applications), the throughput numbers are real: 2-3x improvement on simple routes. For I/O-bound applications where most time is spent waiting for databases, the improvement is more modest but still measurable — typically 10-30% on real workloads.
The migration also pays off in terms of onboarding new engineers. Express's middleware pattern is famous for being confusing to developers who haven't worked with it before — the order of app.use() calls is load-bearing and errors in that ordering produce subtle bugs. Fastify's explicit plugin system and hook lifecycle are more explicit and easier to reason about, especially for developers who come from frameworks with structured request lifecycles.
Dig deeper: compare both frameworks head-to-head on the Express vs Fastify comparison page, read the Express vs Hono comparison for 2026, or check the download trends and changelog on the Fastify package page.
See the live comparison
View express vs. fastify on PkgPulse →