How to Migrate from Express.js to Fastify
How do I migrate from Express.js to Fastify?
TL;DR
- Bottom line: Migrate incrementally using
@fastify/expressas a bridge — wrap your Express app in Fastify, then convert routes and middleware to native Fastify plugins one at a time until Express can be removed. - Key tool/command:
npm install fastify @fastify/expressthenawait fastify.register(require('@fastify/express')); fastify.use(expressApp) - Watch out for: Treating Fastify plugins like Express middleware — plugins use encapsulated scope, not a linear chain, so registering a plugin in a child context does not affect sibling or parent routes.
- Works with: Fastify 5.8.5 (current, Apr 2026), Node.js 20+/22+, TypeScript 5.x. Express 4.x/5.x middleware supported via
@fastify/expressv4.0.6 bridge.
Constraints
- Fastify v5 requires Node.js 20+ — do not attempt migration if production runs Node 18 or earlier. Fastify v4 LTS ended June 30, 2025. [src6]
- Never use
reply.send()ANDreturna value in the same async handler — this causesFST_ERR_PROMISE_NOT_FULFILLED. Pick one:return dataorreply.send(data). [src1] - The
@fastify/expressbridge does NOT support HTTP/2 — migrate all routes to native Fastify before enabling HTTP/2. [src4] - Every JSON Schema must include the top-level
typeproperty (e.g.,type: 'object'). Fastify v5 silently passes validation without it. [src6] - Do not install
body-parser— Fastify parsesapplication/jsonandtext/plainby default. Double-parsing causes subtle bugs and wasted CPU. [src1] - Plugin encapsulation is NOT middleware — a decorator or hook inside a child plugin is invisible to sibling/parent plugins. Use
fastify-plugin(fp()) to intentionally break encapsulation. [src1, src2]
Quick Reference
| Express Pattern | Fastify Equivalent | Example |
|---|---|---|
app.get('/path', handler) | fastify.get('/path', handler) | fastify.get('/users', async (request, reply) => { return users }) |
(req, res, next) signature | (request, reply) signature | async (request, reply) => { return data } |
res.json(data) | return data (auto-serialized) | return { id: 1, name: 'Alice' } |
res.status(404).json(err) | reply.code(404).send(err) | reply.code(404).send({ error: 'Not found' }) |
app.use(middleware) | fastify.register(plugin) | fastify.register(require('@fastify/cors')) |
express.Router() | Plugin with prefix | fastify.register(userRoutes, { prefix: '/api/users' }) |
express.static('public') | @fastify/static plugin | fastify.register(require('@fastify/static'), { root: path.join(__dirname, 'public') }) |
express.json() body parser | Built-in (automatic) | JSON parsing is on by default, no middleware needed |
app.use((err, req, res, next) => {}) | fastify.setErrorHandler() | fastify.setErrorHandler((error, request, reply) => { reply.code(500).send({ error }) }) |
express-validator / Joi | Built-in JSON Schema | fastify.post('/users', { schema: { body: userSchema } }, handler) |
morgan / winston logging | Built-in Pino logger | const fastify = Fastify({ logger: true }) then request.log.info('msg') |
helmet middleware | @fastify/helmet plugin | fastify.register(require('@fastify/helmet')) |
cors middleware | @fastify/cors plugin | fastify.register(require('@fastify/cors'), { origin: '*' }) |
compression middleware | @fastify/compress plugin | fastify.register(require('@fastify/compress')) |
app.listen(3000, callback) | await fastify.listen({ port: 3000 }) | await fastify.listen({ port: 3000, host: '0.0.0.0' }) |
req.connection | request.socket | request.socket.remoteAddress (v5: request.connection removed) |
res.redirect(302, url) | reply.redirect(url, 302) | reply.redirect('/new-path', 301) (v5: reversed args) |
Custom logger: {logger: pinoInstance} | {loggerInstance: pinoInstance} | Fastify({ loggerInstance: pino() }) (v5: logger no longer accepts instances) |
Decision Tree
START
├── Is this a greenfield project or adding Fastify to an existing Express app?
│ ├── GREENFIELD → Start fresh with Fastify, skip the bridge
│ └── EXISTING EXPRESS APP ↓
├── How large is the Express codebase?
│ ├── SMALL (<20 routes, <5 middleware) → Rewrite all routes directly, no bridge needed
│ └── MEDIUM/LARGE ↓
├── Can you freeze feature development during migration?
│ ├── YES → Rewrite route-by-route without the bridge, deploy as a batch
│ └── NO → Use @fastify/express bridge for incremental migration
├── Do you use Express-specific middleware with no Fastify equivalent?
│ ├── YES → Keep them running via @fastify/express, migrate last
│ └── NO ↓
├── Do you use custom Express error middleware (4-arg functions)?
│ ├── YES → Convert to fastify.setErrorHandler() early
│ └── NO ↓
├── Are you targeting HTTP/2?
│ ├── YES → Cannot use @fastify/express bridge; migrate all routes to native Fastify first
│ └── NO ↓
└── DEFAULT → Migrate routes one-by-one, replace middleware with @fastify/* plugins, add JSON Schema validation, remove Express when done
Decision Logic
Structured rules an agent can apply directly when advising a migration. Use the inputs_needed answers to select the path.
If production runs Node.js 18 or earlier
→ Do NOT start a Fastify v5 migration. Upgrade Node to 20+ first (or 22 LTS); Fastify v5 hard-requires Node 20+ and v4 reached EOL on 2025-06-30. [src1, src6]
If the codebase is small (<20 routes, <5 middleware)
→ Skip the @fastify/express bridge. Rewrite all routes directly to native Fastify and deploy as one batch — the bridge overhead is not worth it at this size. [src2, src3]
If the codebase is medium/large and feature development cannot freeze
→ Use the @fastify/express v4.0.6 bridge for incremental, route-by-route migration; the first PR only adds Fastify and wraps the Express app, each later PR moves a few routes. [src2, src5, src9]
If you must enable HTTP/2
→ Migrate every route to native Fastify before turning HTTP/2 on. @fastify/express does not support HTTP/2 because Express cannot use Node's core HTTP/2 module. [src4]
If the app uses express-validator, Joi, or celebrate for validation
→ Replace them with Fastify's built-in JSON Schema (Ajv) per route; there is no automated Joi-to-JSON-Schema converter, so budget rewrite time, and always include the top-level type property. [src1, src6]
If you need per-route request timeouts
→ Use Fastify v5.8.0+ first-class handler-level timeouts instead of porting Express timeout middleware. [src9]
If the app relies on Express-only middleware with no Fastify equivalent (e.g., Passport strategies)
→ Keep those routes running through @fastify/express and migrate them last; do not block the rest of the migration on them. [src2, src4]
Step-by-Step Guide
1. Install Fastify alongside Express
Add Fastify and the Express compatibility bridge to your existing project. Both frameworks can coexist on the same server during migration. [src2, src4]
npm install fastify @fastify/express
Verify: node -e "const f = require('fastify'); console.log(f().version)" → prints Fastify version (e.g., 5.8.5)
2. Wrap Express app with Fastify bridge
Create a Fastify instance that proxies all requests to your existing Express app. This lets Fastify handle the server while Express routes still work. [src4, src5]
// server.js — bridge setup
const Fastify = require('fastify');
const expressApp = require('./app'); // your existing Express app
const fastify = Fastify({ logger: true });
async function start() {
// Register the Express compatibility layer
await fastify.register(require('@fastify/express'));
// Mount your entire Express app under Fastify
fastify.use(expressApp);
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log(`Server running on ${fastify.server.address().port}`);
}
start();
Verify: curl http://localhost:3000/your-existing-route → same response as before. All Express routes still work.
3. Migrate routes from Express to native Fastify
Convert Express route handlers one at a time. Change (req, res) to (request, reply) and return data instead of calling res.json(). [src2, src3]
// BEFORE: Express route
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
} catch (err) {
res.status(500).json({ error: 'Internal server error' });
}
});
// AFTER: Fastify route (register as a plugin)
async function userRoutes(fastify, options) {
fastify.get('/api/users/:id', {
schema: {
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id']
}
}
}, async (request, reply) => {
const user = await db.findUser(request.params.id);
if (!user) {
reply.code(404);
return { error: 'Not found' };
}
return user; // auto-serialized to JSON
});
}
fastify.register(userRoutes);
Verify: curl http://localhost:3000/api/users/123 → same JSON response. Remove the Express version of the route.
4. Replace Express middleware with Fastify plugins
Convert common middleware to their Fastify equivalents. Each Express app.use() becomes a fastify.register() call. [src2, src4]
// BEFORE: Express middleware stack
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
app.use(cors({ origin: '*' }));
app.use(helmet());
app.use(compression());
app.use(express.json());
// AFTER: Fastify plugin registration
const Fastify = require('fastify');
const fastify = Fastify({ logger: true }); // JSON body parsing is built-in
fastify.register(require('@fastify/cors'), { origin: '*' });
fastify.register(require('@fastify/helmet'));
fastify.register(require('@fastify/compress'));
Verify: Check response headers for CORS, security headers, and compression. curl -v http://localhost:3000/api/users should show the same headers.
5. Add JSON Schema validation
Replace express-validator, Joi, or celebrate with Fastify's built-in JSON Schema validation. [src1, src2]
fastify.post('/api/users', {
schema: {
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 2 }
}
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
// request.body is already validated
const user = await db.createUser(request.body);
reply.code(201);
return user;
});
Verify: curl -X POST -H 'Content-Type: application/json' -d '{"email":"bad"}' http://localhost:3000/api/users → 400 with validation error.
6. Convert error handling
Replace Express's 4-argument error middleware with Fastify's setErrorHandler(). Fastify catches async errors automatically. [src2, src4]
fastify.setErrorHandler((error, request, reply) => {
request.log.error(error);
if (error.validation) {
return reply.code(400).send({
error: 'Validation Error',
details: error.validation
});
}
const statusCode = error.statusCode || 500;
reply.code(statusCode).send({
error: statusCode >= 500 ? 'Internal server error' : error.message
});
});
Verify: Throw an error in any route handler — Fastify catches it and passes it to setErrorHandler(). No next(err) pattern needed.
7. Remove Express and clean up
Once all routes, middleware, and error handling are migrated, uninstall Express and related packages. [src5]
npm uninstall express cors helmet compression morgan express-validator body-parser
npm uninstall @fastify/express
Verify: grep -rn "require('express')" --include='*.js' --include='*.ts' → zero results. npm ls express → not found.
Code Examples
JavaScript: Complete Express-to-Fastify server migration
// Input: An Express server with routes, middleware, and error handling
// Output: Equivalent Fastify server with plugins and JSON Schema validation
const Fastify = require('fastify');
// Create Fastify instance (replaces const app = express())
const fastify = Fastify({
logger: true, // Built-in Pino logger (replaces morgan/winston)
ajv: {
customOptions: {
removeAdditional: true, // Strip unknown properties
coerceTypes: true // Auto-coerce query string types
}
}
});
// Register plugins (replaces app.use(middleware))
fastify.register(require('@fastify/cors'), { origin: true });
fastify.register(require('@fastify/helmet'));
fastify.register(require('@fastify/compress'));
// Register route plugins with prefix (replaces express.Router)
fastify.register(require('./routes/users'), { prefix: '/api/users' });
fastify.register(require('./routes/products'), { prefix: '/api/products' });
// Global error handler (replaces app.use((err, req, res, next) => {}))
fastify.setErrorHandler((error, request, reply) => {
request.log.error({ err: error }, 'Request error');
if (error.validation) {
return reply.code(400).send({ error: 'Bad Request', details: error.validation });
}
reply.code(error.statusCode || 500).send({
error: error.statusCode >= 500 ? 'Internal Server Error' : error.message
});
});
// 404 handler (replaces app.use((req, res) => res.status(404)...))
fastify.setNotFoundHandler((request, reply) => {
reply.code(404).send({ error: 'Route not found' });
});
// Start server (replaces app.listen(port, callback))
const start = async () => {
try {
await fastify.listen({ port: process.env.PORT || 3000, host: '0.0.0.0' });
} catch (err) {
fastify.log.fatal(err);
process.exit(1);
}
};
start();
TypeScript: Fastify route plugin with full type safety
// Input: Express route file with TypeScript
// Output: Fastify plugin with typed schema, request, and reply
import { FastifyPluginAsync } from 'fastify';
interface UserParams { id: string; }
interface CreateUserBody { email: string; name: string; role?: 'admin' | 'user'; }
interface UserResponse { id: string; email: string; name: string; role: string; createdAt: string; }
const userRoutes: FastifyPluginAsync = async (fastify, opts) => {
// GET /api/users/:id
fastify.get<{ Params: UserParams; Reply: UserResponse }>(
'/:id',
{
schema: {
params: {
type: 'object',
properties: { id: { type: 'string', format: 'uuid' } },
required: ['id']
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' }, email: { type: 'string' },
name: { type: 'string' }, role: { type: 'string' },
createdAt: { type: 'string' }
}
}
}
}
},
async (request, reply) => {
const user = await fastify.db.findUser(request.params.id);
if (!user) { reply.code(404); return { error: 'User not found' }; }
return user;
}
);
// POST /api/users
fastify.post<{ Body: CreateUserBody; Reply: UserResponse }>(
'/',
{
schema: {
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 2 },
role: { type: 'string', enum: ['admin', 'user'], default: 'user' }
}
}
}
},
async (request, reply) => {
const user = await fastify.db.createUser(request.body);
reply.code(201);
return user;
}
);
};
export default userRoutes;
JavaScript: Hooks as middleware replacement
// Input: Express middleware chain for auth + rate limiting
// Output: Fastify hooks and decorators achieving the same flow
const fp = require('fastify-plugin');
// Authentication plugin (replaces app.use(authMiddleware))
const authPlugin = fp(async function (fastify, opts) {
fastify.decorate('authenticate', async function (request, reply) {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
reply.code(401);
throw new Error('Missing authentication token');
}
try {
request.user = await fastify.jwt.verify(token);
} catch (err) {
reply.code(401);
throw new Error('Invalid token');
}
});
});
// Register auth plugin globally
fastify.register(authPlugin);
// Apply auth to specific routes using preHandler hook
fastify.register(async function protectedRoutes(fastify) {
fastify.addHook('preHandler', fastify.authenticate);
fastify.get('/api/profile', async (request) => {
return { user: request.user };
});
fastify.get('/api/settings', async (request) => {
return { settings: await db.getSettings(request.user.id) };
});
});
// Public routes — no auth hook applied
fastify.get('/api/health', async () => ({ status: 'ok' }));
Anti-Patterns
Wrong: Wrapping every handler in try/catch like Express
// ❌ BAD — unnecessary try/catch; Fastify handles async errors automatically
fastify.get('/api/users/:id', async (request, reply) => {
try {
const user = await db.findUser(request.params.id);
if (!user) {
reply.code(404).send({ error: 'Not found' });
return;
}
reply.send(user);
} catch (err) {
reply.code(500).send({ error: 'Internal server error' });
}
});
Correct: Let Fastify handle errors automatically
// ✅ GOOD — Fastify catches rejected promises and routes to setErrorHandler
fastify.get('/api/users/:id', async (request, reply) => {
const user = await db.findUser(request.params.id);
if (!user) {
reply.code(404);
return { error: 'Not found' };
}
return user; // auto-serialized, no reply.send() needed
});
Wrong: Registering auth middleware globally like Express
// ❌ BAD — treating plugins like Express global middleware
fastify.register(require('@fastify/cors'));
fastify.register(require('@fastify/auth'));
// Every route gets auth, even public ones
fastify.get('/api/health', async () => ({ status: 'ok' }));
fastify.get('/api/protected', async (request) => ({ user: request.user }));
Correct: Use plugin encapsulation for scoped middleware
// ✅ GOOD — encapsulate auth to only the routes that need it
fastify.register(require('@fastify/cors')); // global: fine
// Public routes
fastify.get('/api/health', async () => ({ status: 'ok' }));
// Protected routes in their own scope
fastify.register(async function (fastify) {
fastify.addHook('preHandler', fastify.authenticate);
fastify.get('/api/protected', async (request) => ({ user: request.user }));
});
Wrong: Manual validation instead of JSON Schema
// ❌ BAD — manual validation like Express, missing Fastify's core advantage
fastify.post('/api/users', async (request, reply) => {
const { email, name } = request.body;
if (!email || !email.includes('@')) {
reply.code(400);
return { error: 'Invalid email' };
}
if (!name || name.length < 2) {
reply.code(400);
return { error: 'Name too short' };
}
return await db.createUser({ email, name });
});
Correct: Declare JSON Schema for validation and serialization
// ✅ GOOD — schema validated before handler runs, response serialized faster
fastify.post('/api/users', {
schema: {
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 2 }
}
}
}
}, async (request, reply) => {
reply.code(201);
return await db.createUser(request.body); // body is guaranteed valid
});
Wrong: Mixing reply.send() with return values
// ❌ BAD — calling reply.send() AND returning a value causes FST_ERR_PROMISE_NOT_FULFILLED
fastify.get('/api/data', async (request, reply) => {
const data = await fetchData();
reply.send(data);
return data; // double response — Fastify throws an error
});
Correct: Use either return OR reply.send(), never both
// ✅ GOOD — return data for automatic serialization
fastify.get('/api/data', async (request, reply) => {
const data = await fetchData();
return data;
});
Wrong: Using deprecated Fastify v4 patterns in v5
// ❌ BAD — these patterns break in Fastify v5
const pino = require('pino')();
const fastify = Fastify({ logger: pino }); // v5: logger no longer accepts instances
fastify.listen(3000, '0.0.0.0', callback); // v5: variadic listen() removed
const time = reply.getResponseTime(); // v5: removed
Correct: Use v5 API for logger, listen, and response time
// ✅ GOOD — Fastify v5 correct syntax
const pino = require('pino')();
const fastify = Fastify({ loggerInstance: pino }); // v5: use loggerInstance
await fastify.listen({ port: 3000, host: '0.0.0.0' }); // v5: options object only
const time = reply.elapsedTime; // v5: replaces getResponseTime()
Common Pitfalls
- Plugin encapsulation confusion: Fastify plugins create isolated scopes. A decorator or hook registered inside a plugin is NOT available to sibling or parent plugins. Fix: Use
fastify-plugin(fp()) wrapper to break encapsulation when you need shared decorators. [src1, src2] - Forgetting body parsing is built-in: Installing
body-parseror callingfastify.use(express.json())is unnecessary and can cause double-parsing. Fix: Remove body-parser — Fastify parsesapplication/jsonandtext/plainby default. [src1] - Using
reply.send()withreturnin async handlers: This triggersFST_ERR_PROMISE_NOT_FULFILLEDbecause Fastify tries to send the response twice. Fix: Usereturn datafor auto-serialization, orreply.send(data)without returning. Never both. [src1] - Not converting
listen()call syntax: Express'sapp.listen(3000, callback)will not work. Fastify v5 removed variadic arguments. Fix: Useawait fastify.listen({ port: 3000, host: '0.0.0.0' })with the options object. [src6] - Expecting middleware execution order like Express: Express middleware runs in registration order for ALL routes. Fastify hooks run only within their encapsulated scope. Fix: Register hooks in the correct plugin scope, or use
fastify-pluginfor global hooks. [src2] - Missing the
typeproperty in JSON Schema: Fastify v5 requires full JSON Schema — every schema must includetype: 'object'. Omitting it causes validation to silently pass everything. Fix: Always includetypeat the top level of every schema definition. [src6] - Not removing the @fastify/express bridge after migration: The bridge adds overhead. Leaving it in production defeats the performance gains. Fix: Remove
@fastify/expressandnpm uninstall expressonce all routes are converted. [src4, src5] - Ignoring Fastify's lifecycle hooks: Express has a simple request → middleware → response flow. Fastify has
onRequest → preParsing → preValidation → preHandler → handler → preSerialization → onSend → onResponse. Fix: Map your middleware logic to the correct hook — auth inpreHandler, logging inonRequest, transforms inpreSerialization. [src1] - Using
loggeroption with a custom Pino instance in v5: Fastify v5 no longer accepts a custom logger via theloggeroption. Fix: UseloggerInstanceinstead —Fastify({ loggerInstance: pinoInstance }). [src6] - Using
reply.redirect(code, url)in v5: The argument order reversed in v5 — it is nowreply.redirect(url, code). Fix: Swap the arguments or use the 1-arg formreply.redirect(url)for 302. [src6]
Diagnostic Commands
# Verify Fastify version
node -e "console.log(require('fastify/package.json').version)"
# Check for remaining Express imports in the codebase
grep -rn "require('express')\|from 'express'" --include='*.js' --include='*.ts' --include='*.mjs'
# Verify no Express dependencies remain
npm ls express body-parser cors helmet morgan express-validator
# Print all registered Fastify routes and schemas
npx fastify-print-routes
# Test JSON Schema validation
curl -X POST -H 'Content-Type: application/json' -d '{"bad":"data"}' http://localhost:3000/api/users
# Benchmark before and after migration
npx autocannon -c 100 -d 10 http://localhost:3000/api/users
# Check Fastify server health
curl http://localhost:3000/health
# Verify Node.js version meets Fastify v5 minimum
node -e "const [major] = process.versions.node.split('.'); console.log(major >= 20 ? 'OK: Node ' + process.version : 'FAIL: Fastify v5 requires Node 20+');"
# Check for deprecated Fastify v4 patterns
grep -rn "getResponseTime\|reply\.redirect([0-9]\|logger:.*require('pino')" --include='*.js' --include='*.ts'
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Fastify 5.8.5 (Apr 2026) | Current | Full JSON Schema required; listen() requires options object; reply.redirect(url, code) reversed args; loggerInstance replaces logger for custom loggers; request.connection removed; semicolon query delimiters off by default | Patches CVE-2026-33806 (5.8.5), CVE-2026-3635 (5.8.3), CVE-2026-3419 (5.8.1, content-type bypass), CVE-2026-25224 (5.7.3). v5.8.0 added first-class handler-level timeouts. Upgrade from v4: fix deprecation warnings first, add type to all schemas |
| Fastify 5.7.x (Feb 2026) | Maintenance | Stricter RFC 9110 content-type header parsing (5.7.2) | Bump to 5.8.5 for the latest security patches |
| Fastify 5.0 (Sep 2024) | Stable | 20+ breaking changes. Minimum Node.js 20. Non-standard HTTP methods removed | Major release via OpenJS Foundation. Performance 5-10% faster than v4 |
| Fastify 4.x (2022–2025) | EOL (June 30, 2025) | reply.send() no longer returns promise; content type parser changes | Was LTS; most migration guides still reference v4 patterns |
| Fastify 3.x (2020–2022) | EOL | Middleware removed from core | Last version with built-in middleware support; requires @fastify/express or @fastify/middie |
| Express 5.x (2024) | Current | req.host → req.hostname; regex route changes; removed several deprecated methods | If on Express 5, migration patterns to Fastify are similar |
| Express 4.x (2014–present) | Maintenance | Minimal — stable for years | Source framework for migration; no active feature development |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| API-heavy app needing >10K req/sec throughput | Simple static file server with minimal routing | Express or serve-static |
| You want built-in validation, serialization, logging | Rapid prototype where Express knowledge saves time | Stay with Express |
| TypeScript-first codebase needing type-safe routes | App relies heavily on Express-only middleware (e.g., Passport without Fastify adapter) | Express + specific middleware |
| Microservices architecture with plugin encapsulation | Team is unfamiliar with Fastify and has no time to learn | Keep Express, optimize later |
| Need native OpenAPI/Swagger spec generation | Serverless functions (Cloudflare Workers, Vercel Edge) | Hono or framework-agnostic handlers |
| Moving to Node.js 20+/22+ and want modern framework | Stuck on Node.js 18 or earlier in production | Stay on Express or use Fastify v4 (EOL) |
Important Caveats
- Fastify v5 requires Node.js 20+. If your production environment runs Node 18, stay on Fastify v4.x (but note: v4 went EOL June 30, 2025 — plan your Node upgrade).
- The
@fastify/expressbridge does NOT support HTTP/2. If you need HTTP/2, migrate all routes to native Fastify before enabling it. - Fastify benchmarks show ~3-5x throughput vs Express (≈70K–80K req/sec vs ≈20K–30K req/sec in 2026), but real-world gains depend on your I/O patterns. Database-bound apps may see 20-40% improvement rather than 5x.
- JSON Schema validation runs via Ajv. If your Express app uses Joi or Yup schemas, you must rewrite them as JSON Schema — there is no automated converter.
- Plugin encapsulation means
fastify.decorate()inside a child plugin is NOT visible to sibling plugins. Usefastify-pluginwrapper to share decorators across the app. - Stay current on patches: Fastify 5.8.5 (Apr 2026) fixes CVE-2026-33806 (port parsing), and 5.8.x earlier patched CVE-2026-3635 (5.8.3) and CVE-2026-3419 (5.8.1, a content-type validation bypass). Run at least 5.8.5 in production.
- The
@fastify/expressbridge is a temporary tool only (current v4.0.6, requires Fastify ^5.x); it does not support async middleware and is not a long-term solution — remove it once all routes are native Fastify. - Fastify v5 removed support for non-standard HTTP methods (PROPFIND, TRACE, SEARCH, etc.). If your Express app handles WebDAV or similar, you will need a workaround.
- Fastify v5 query string parsing no longer supports semicolon delimiters by default (per RFC 3986). If your Express app uses semicolons in query strings, set
useSemicolonDelimiter: true.