Express has long been fundamental to Node.js web development, supporting millions of applications with its straightforward, adaptable design.
Although Express provides great flexibility through its minimalistic philosophy, Fastify offers built-in validation, modern TypeScript support, and a plugin system optimized for scalability.
Express operates with Node.js 16+ and depends on external libraries for validation and other features, whereas Fastify needs Node.js 20+ and adopts a schema-first approach that removes the need for external dependencies for common functions.
This comprehensive guide walks through migrating from Express to Fastify, highlighting the architectural differences and performance benefits, and provides practical strategies for a successful transition.
Understanding the architectural differences
Express and Fastify take fundamentally different approaches to building web applications. Understanding these differences is crucial for planning your migration strategy.
Express follows a minimalist philosophy with maximum flexibility through middleware chains. This approach offers tremendous versatility but requires manual error handling for async operations:
// Express with manual error handling
app.get('/data/:id', async (req, res, next) => {
try {
const result = await fetchUserData(req.params.id);
res.json(result);
} catch (error) {
next(error);
}
});
Fastify embraces a performance-first architecture with opinionated design choices that enhance speed and developer productivity. It requires schemas and uses an encapsulated plugin system:
// Fastify with schema-driven approach
const schema = {
params: { type: 'object', properties: { id: { type: 'string' } } }
};
fastify.get('/data/:id', { schema }, async (request, reply) => {
const result = await fetchUserData(request.params.id);
return result; // Automatic validation and serialization
});
The plugin system creates isolated contexts that prevent conflicts between different parts of your application. Unlike Express middleware that shares global state, Fastify plugins maintain their own scope while still allowing controlled sharing of resources when needed.
This architectural shift means thinking less about middleware order and more about plugin organization and dependency injection.
Request and response handling transformation
Moving from Express's req
and res
objects to Fastify's request
and reply
requires adjusting to new patterns and capabilities. While the concepts remain similar, the implementation details and available methods differ significantly.
Express developers are accustomed to directly manipulating response objects and accessing request data through various properties:
// Express request/response pattern
app.get('/api/data/:id', async (req, res, next) => {
try {
const userId = req.params.id;
const result = await fetchData(userId);
res.status(200).json(result);
} catch (error) {
next(error);
}
});
Fastify encourages a more functional approach with automatic error handling and schema validation:
// Fastify with built-in validation
const schema = {
params: { type: 'object', properties: { id: { type: 'string' } } }
};
fastify.get('/api/data/:id', { schema }, async (request, reply) => {
const { id } = request.params;
const result = await fetchData(id);
return result; // Automatic serialization
});
Notice how Fastify handlers can return data directly instead of explicitly calling response methods. This pattern leverages Fastify's automatic serialization and reduces boilerplate code.
Request validation becomes declarative through schemas rather than imperative middleware checks. Instead of writing custom validation logic, you define expected data structures upfront, eliminating manual validation code while providing automatic documentation and type safety benefits.
Middleware to plugin conversion strategies
The most significant mindset shift involves replacing Express middleware with Fastify plugins. While middleware functions execute in sequence for every request, plugins create reusable functionality that can be selectively applied to different route contexts.
Express middleware typically follows this pattern for cross-cutting concerns:
// Express middleware approach
import jwt from 'jsonwebtoken';
const authMiddleware = async (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
res.status(403).json({ error: 'Invalid token' });
}
};
app.use('/api/protected', authMiddleware);
Fastify plugins encapsulate similar functionality while offering better performance and clearer boundaries:
// Fastify plugin approach
import jwt from 'jsonwebtoken';
import fp from 'fastify-plugin';
async function authPlugin(fastify, options) {
fastify.decorateRequest('user', null);
fastify.addHook('preHandler', async (request, reply) => {
const token = request.headers.authorization;
if (!token) throw new Error('No token provided');
request.user = jwt.verify(token, options.secret);
});
}
await fastify.register(authPlugin, {
secret: process.env.JWT_SECRET,
prefix: '/api/protected'
});
The plugin approach provides several advantages: better error handling through Fastify's error system, automatic context isolation, and the ability to pass configuration options during registration.
For complex middleware chains, consider breaking them into focused plugins that handle specific concerns. This modular approach improves testability and makes it easier to reason about your application's behavior.
Schema definition and validation migration
Fastify's built-in JSON Schema validation represents one of its most powerful features, eliminating the need for external validation libraries while providing automatic documentation and type generation capabilities.
Express applications typically rely on external libraries like Joi, Yup, or express-validator for request validation:
// Express with external validation
import { body, param, validationResult } from 'express-validator';
app.post('/users/:id', [
param('id').isUUID(),
body('name').isString().isLength({ min: 2, max: 50 }),
body('email').isEmail()
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process valid data
const { name, email } = req.body;
// ... rest of handler
});
Fastify handles validation declaratively through JSON Schema definitions that integrate seamlessly with the routing system:
// Fastify with built-in schema validation
const schema = {
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 2, maxLength: 50 },
email: { type: 'string', format: 'email' }
},
required: ['name', 'email']
}
};
fastify.post('/users/:id', { schema }, async (request, reply) => {
// All validation happens automatically before this handler runs
const { id } = request.params;
const userData = request.body;
const updatedUser = await updateUser(id, userData);
return updatedUser;
});
The schema approach provides multiple benefits beyond validation: automatic API documentation generation, response serialization optimization, and potential TypeScript type generation for enhanced development experience.
Error handling transformation
Fastify's error handling system provides a more structured and performant approach compared to Express's traditional error middleware pattern.
Express typically handles errors through a special middleware function with four parameters:
// Express error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
if (err.name === 'ValidationError') {
return res.status(400).json({ error: 'Validation failed' });
}
res.status(500).json({ error: 'Internal server error' });
});
Fastify uses a more sophisticated error handling system with automatic error serialization and customizable error responses:
// Fastify error handling
fastify.setErrorHandler(async (error, request, reply) => {
request.log.error(error);
if (error.validation) {
return reply.status(400).send({
error: 'Validation Error',
message: 'Request validation failed',
details: error.validation
});
}
if (error.statusCode >= 500) {
return reply.status(500).send({
error: 'Internal Server Error',
message: 'Something went wrong'
});
}
return reply.send(error);
});
For application-specific errors, create custom error classes that integrate well with Fastify's error system:
// Custom error classes
class BusinessLogicError extends Error {
constructor(message, statusCode = 400) {
super(message);
this.name = 'BusinessLogicError';
this.statusCode = statusCode;
}
}
// Usage in route handlers
fastify.get('/users/:id', async (request, reply) => {
const user = await findUser(request.params.id);
if (!user) {
throw new BusinessLogicError('User not found', 404);
}
return user;
});
Route organization and modularization
Fastify's plugin system encourages better route organization compared to Express's flat routing structure. Moving from Express routers to Fastify plugins requires rethinking how you group and structure your application's endpoints.
Express typically organizes routes using separate router instances:
// Express route organization
import express from 'express';
const userRouter = express.Router();
userRouter.get('/', getAllUsers);
userRouter.get('/:id', getUser);
userRouter.post('/', createUser);
app.use('/api/users', userRouter);
Fastify encourages organizing routes into plugins that can encapsulate related functionality:
// Fastify plugin organization
import fp from 'fastify-plugin';
async function userRoutes(fastify, options) {
const userService = new UserService(options.database);
fastify.decorate('userService', userService);
fastify.get('/', async () => fastify.userService.findAll());
fastify.get('/:id', {
schema: { params: { type: 'object', properties: { id: { type: 'string' } } } }
}, async (request) => {
return fastify.userService.findById(request.params.id);
});
}
export default fp(userRoutes, { name: 'user-routes' });
await fastify.register(userRoutes, {
prefix: '/api/users',
database: databaseConnection
});
This plugin-based approach provides several benefits: better dependency injection through options, cleaner separation of concerns, improved testability through isolated contexts, and the ability to apply middleware-like functionality at the plugin level.
Testing strategy adaptation
Testing Fastify applications requires adapting your testing approach to work with the plugin system and built-in testing utilities. The framework provides excellent testing support that often simplifies test setup compared to Express applications.
Express testing typically involves setting up the entire application and using libraries like Supertest:
// Express testing approach
import request from 'supertest';
import app from '../app.js';
describe('User API', () => {
it('should get user by id', async () => {
const user = await createTestUser();
const response = await request(app)
.get(`/api/users/${user.id}`)
.expect(200);
expect(response.body.id).toBe(user.id);
});
});
Fastify provides built-in testing utilities that integrate seamlessly with the plugin system:
// Fastify testing approach
import { build } from '../app.js';
describe('User API', () => {
let app;
beforeEach(async () => {
app = build({ logger: false });
await app.ready();
});
afterEach(() => app.close());
it('should get user by id', async () => {
const user = await createTestUser();
const response = await app.inject({
method: 'GET',
url: `/api/users/${user.id}`
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(user.id);
});
});
Fastify's inject
method provides several advantages: no need for external HTTP client libraries, direct access to the application instance for mocking, automatic handling of lifecycle events, and better performance through direct function calls instead of HTTP requests.
Performance optimization techniques
One of the primary motivations for migrating to Fastify is its superior performance characteristics. However, realizing these benefits requires understanding and implementing Fastify-specific optimization techniques.
Fastify achieves better performance through several mechanisms: optimized route matching, schema-based serialization, object reuse, and efficient plugin encapsulation.
Schema-driven serialization provides significant performance improvements over generic JSON serialization:
// Optimized response serialization with schemas
const userResponseSchema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' }
}
};
fastify.get('/users/:id', {
schema: { response: { 200: userResponseSchema } }
}, async (request, reply) => {
const user = await getUserWithProfile(request.params.id);
return user; // Fastify optimizes serialization based on schema
});
Connection pooling and resource management becomes more important with Fastify's higher throughput capabilities:
// Optimized database connection handling
import { Pool } from 'pg';
const dbPlugin = async (fastify, options) => {
const pool = new Pool({
host: options.host,
max: 20,
idleTimeoutMillis: 30000
});
fastify.decorate('db', pool);
fastify.addHook('onClose', async (instance) => {
await instance.db.end();
});
};
await fastify.register(dbPlugin, databaseConfig);
These optimization techniques can result in significant performance improvements, especially under high load conditions where Fastify's architecture really shines.
Migration checklist and best practices
Successfully migrating from Express to Fastify requires systematic planning and execution. This comprehensive checklist ensures you don't miss critical migration steps while maintaining application stability.
Pre-migration assessment:
Start by auditing your current Express application architecture. Identify all middleware functions, route handlers, error handling patterns, and external dependencies. Document performance baselines including response times, throughput metrics, and resource utilization to measure migration success.
Incremental migration strategy:
Begin by creating a new Fastify application alongside your existing Express app. Start migrating simple, isolated routes that don't depend heavily on shared middleware or complex error handling.
// Hybrid approach during migration
import express from 'express';
import Fastify from 'fastify';
import { createProxyMiddleware } from 'http-proxy-middleware';
const expressApp = express();
const fastify = Fastify({ logger: true });
// Proxy specific routes to Fastify during migration
expressApp.use('/api/v2', createProxyMiddleware({
target: 'http://localhost:3001'
}));
// Keep existing Express routes during transition
expressApp.use('/api/v1', existingExpressRoutes);
Code transformation guidelines:
Transform Express middleware to Fastify plugins systematically. Each middleware function should become a focused plugin with clear responsibilities and proper error handling. Replace imperative validation with declarative JSON schemas.
Testing and validation strategy:
Implement comprehensive integration tests that cover all migrated functionality. Use Fastify's built-in testing utilities to create reliable test suites. Perform load testing to validate performance improvements.
Deployment considerations:
Plan deployment strategies that minimize downtime and allow for quick rollback if issues arise. Consider blue-green deployments or canary releases to gradually shift traffic to the Fastify application.
Final thoughts
Migrating from Express to Fastify is an architectural upgrade that boosts performance, maintainability, and developer experience.
It involves adopting new patterns like plugin-based architecture and schema-driven development. Success requires incremental migration, thorough testing, and leveraging Fastify’s strengths.
With strong community support and documentation, Fastify is an excellent choice for modernizing Node.js apps. Start small to see how it can enhance your workflow and performance.
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github