@fastify/express as 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.npm install fastify @fastify/express then await fastify.register(require('@fastify/express')); fastify.use(expressApp)@fastify/express bridge.reply.send() AND return a value in the same async handler — this causes FST_ERR_PROMISE_NOT_FULFILLED. Pick one: return data or reply.send(data). [src1]@fastify/express bridge does NOT support HTTP/2 — migrate all routes to native Fastify before enabling HTTP/2. [src4]type property (e.g., type: 'object'). Fastify v5 silently passes validation without it. [src6]body-parser — Fastify parses application/json and text/plain by default. Double-parsing causes subtle bugs and wasted CPU. [src1]fastify-plugin (fp()) to intentionally break encapsulation. [src1, src2]| 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) |
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
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.7.4)
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.
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.
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.
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.
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.
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.
// 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();
// 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;
// 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' }));
// ❌ 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' });
}
});
// ✅ 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
});
// ❌ 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 }));
// ✅ 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 }));
});
// ❌ 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 });
});
// ✅ 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
});
// ❌ 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
});
// ✅ GOOD — return data for automatic serialization
fastify.get('/api/data', async (request, reply) => {
const data = await fetchData();
return data;
});
// ❌ 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
// ✅ 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()
fastify-plugin (fp()) wrapper to break encapsulation when you need shared decorators. [src1, src2]body-parser or calling fastify.use(express.json()) is unnecessary and can cause double-parsing. Fix: Remove body-parser — Fastify parses application/json and text/plain by default. [src1]reply.send() with return in async handlers: This triggers FST_ERR_PROMISE_NOT_FULFILLED because Fastify tries to send the response twice. Fix: Use return data for auto-serialization, or reply.send(data) without returning. Never both. [src1]listen() call syntax: Express's app.listen(3000, callback) will not work. Fastify v5 removed variadic arguments. Fix: Use await fastify.listen({ port: 3000, host: '0.0.0.0' }) with the options object. [src6]fastify-plugin for global hooks. [src2]type property in JSON Schema: Fastify v5 requires full JSON Schema — every schema must include type: 'object'. Omitting it causes validation to silently pass everything. Fix: Always include type at the top level of every schema definition. [src6]@fastify/express and npm uninstall express once all routes are converted. [src4, src5]onRequest → preParsing → preValidation → preHandler → handler → preSerialization → onSend → onResponse. Fix: Map your middleware logic to the correct hook — auth in preHandler, logging in onRequest, transforms in preSerialization. [src1]logger option with a custom Pino instance in v5: Fastify v5 no longer accepts a custom logger via the logger option. Fix: Use loggerInstance instead — Fastify({ loggerInstance: pinoInstance }). [src6]reply.redirect(code, url) in v5: The argument order reversed in v5 — it is now reply.redirect(url, code). Fix: Swap the arguments or use the 1-arg form reply.redirect(url) for 302. [src6]# 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 | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Fastify 5.7.x (Feb 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 | CVE-2026-25224 patched in 5.7.3. Upgrade from v4: fix all deprecation warnings first, update schemas to include type |
| 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 |
| 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) |
@fastify/express bridge does NOT support HTTP/2. If you need HTTP/2, migrate all routes to native Fastify before enabling it.fastify.decorate() inside a child plugin is NOT visible to sibling plugins. Use fastify-plugin wrapper to share decorators across the app.useSemicolonDelimiter: true.