← Back to DevBytes

Migrating from Express to Fastify: Step-by-Step Guide

Why Migrate from Express to Fastify?

Express has been the de facto Node.js web framework for over a decade. Its minimalist approach and massive ecosystem made it the backbone of countless applications. However, as modern web development demands higher throughput, stricter validation, and better developer ergonomics, Fastify has emerged as a compelling alternative. Fastify is not just "Express but faster" — it represents a fundamental rethinking of how a Node.js server should operate, offering built-in schema validation, a powerful plugin system, comprehensive TypeScript support, and a hook-based lifecycle that gives developers fine-grained control over request processing.

The core motivation for migration comes down to three pillars: performance (Fastify routinely benchmarks 2-5x faster than Express under load), reliability (automatic input validation eliminates entire categories of bugs), and maintainability (the declarative plugin architecture scales beautifully as codebases grow). This guide walks you through a complete, step-by-step migration from Express to Fastify, with practical code examples at every stage.

Understanding the Core Differences

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before diving into code, it's essential to grasp how Fastify differs conceptually from Express. In Express, everything revolves around middleware functions that form a sequential pipeline. Each middleware receives (req, res, next) and can modify the request, send a response, or pass control along. Fastify replaces this flat middleware chain with a structured lifecycle of hooks (onRequest, preHandler, preSerialization, onSend, onResponse, and more) plus encapsulated plugins that scope their own routes, hooks, and decorations. This encapsulation prevents cross-contamination between feature modules — something Express middleware chains struggle with at scale.

Another critical difference is that Fastify treats request and reply as distinct, well-typed objects rather than Node.js-native req and res. Fastify's reply object provides chainable methods like .send(), .code(), and .header() that return the reply instance, enabling fluent APIs. Express requires separate statements or chaining via third-party libraries.

Step 1: Setting Up a Fastify Project

Begin by installing Fastify in your existing project. You can install it alongside Express initially, allowing an incremental migration rather than a risky full rewrite.

npm install fastify
# Optional but recommended dependencies
npm install @fastify/helmet @fastify/cors @fastify/cookie @fastify/session

Create a new Fastify server instance. The factory function fastify() accepts an options object where you can configure logging, body size limits, serializer options, and more. Unlike Express where you call express() and immediately have a singleton app, Fastify encourages creating multiple server instances for testing or microservices.

// server.js - Starting point for Fastify
const fastify = require('fastify')({
  logger: {
    level: 'info',
    transport: {
      target: 'pino-pretty',
      options: { colorize: true }
    }
  },
  bodyLimit: 1048576, // 1MB
  trustProxy: true
});

// Register plugins first, then routes, then start
const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
    console.log(`Server running on ${fastify.server.address().port}`);
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

Notice that Fastify uses an async startup pattern. The server does not begin listening until you explicitly call fastify.listen(), which gives you full control over the initialization sequence — a marked improvement over Express where app.listen() is a single fire-and-forget call.

Step 2: Converting Express Routes to Fastify

Express routes use app.get(path, handler) or router.get(path, handler) with handlers receiving (req, res) or (req, res, next). Fastify routes look similar but use (request, reply) and always return the reply or a value. Here is a side-by-side comparison:

Express Route (Original)

// Express - Typical route with manual validation
const express = require('express');
const router = express.Router();

router.get('/api/users/:id', async (req, res, next) => {
  try {
    const userId = parseInt(req.params.id, 10);
    if (isNaN(userId)) {
      return res.status(400).json({ error: 'Invalid user ID' });
    }
    const user = await fetchUser(userId);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json({ data: user });
  } catch (error) {
    next(error);
  }
});

module.exports = router;

Fastify Route (Migrated)

// Fastify - Route with built-in schema validation
const userSchema = {
  params: {
    type: 'object',
    required: ['id'],
    properties: {
      id: { type: 'integer', minimum: 1 }
    }
  },
  response: {
    200: {
      type: 'object',
      properties: {
        data: {
          type: 'object',
          properties: {
            id: { type: 'integer' },
            name: { type: 'string' },
            email: { type: 'string', format: 'email' }
          }
        }
      }
    },
    404: {
      type: 'object',
      properties: {
        error: { type: 'string' }
      }
    }
  }
};

module.exports = async function userRoutes(fastify, opts) {
  fastify.get('/api/users/:id', {
    schema: userSchema,
    handler: async (request, reply) => {
      const user = await fetchUser(request.params.id);
      if (!user) {
        return reply.code(404).send({ error: 'User not found' });
      }
      return reply.send({ data: user });
    }
  });
};

The Fastify version eliminates manual validation and uses return reply.send() to ensure proper async handling. The schema validates both input parameters and output shape automatically, returning clear 400 errors for invalid requests without writing a single if statement.

Step 3: Adapting Express Middleware to Fastify Hooks

Express middleware typically looks like (req, res, next) => { ... next(); }. Fastify offers several mechanisms to replace this pattern, depending on the middleware's purpose.

Authentication Middleware

In Express, authentication middleware intercepts the request, verifies credentials, and either calls next() or returns an error. In Fastify, this becomes a preHandler hook or a decorateRequest decoration combined with a hook:

// Express authentication middleware
function expressAuth(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) {
    return res.status(401).json({ error: 'Missing token' });
  }
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

app.use('/api', expressAuth);
// Fastify equivalent using a decorator and preHandler hook
const fp = require('fastify-plugin');
const jwt = require('jsonwebtoken');

async function authPlugin(fastify, opts) {
  // Decorate the request with a user property
  fastify.decorateRequest('user', null);

  // Add a preHandler hook that runs before route handlers
  fastify.addHook('preHandler', async (request, reply) => {
    // Skip if the route is explicitly marked as public
    if (request.routeOptions.config?.public) return;

    const authHeader = request.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return reply.code(401).send({ error: 'Missing token' });
    }

    const token = authHeader.substring(7);
    try {
      request.user = jwt.verify(token, process.env.JWT_SECRET);
    } catch (err) {
      return reply.code(401).send({ error: 'Invalid token' });
    }
  });

  // Provide a utility to mark routes as public
  fastify.decorate('publicRoute', function() {
    this.routeOptions.config = { ...this.routeOptions.config, public: true };
    return this;
  });
}

module.exports = fp(authPlugin, { name: 'auth' });

The Fastify approach uses fastify-plugin (the fp wrapper) to ensure the plugin's decorations and hooks are shared across the entire application. The preHandler hook runs after route matching but before the handler, making it the ideal place for authentication logic. Routes can opt-out using a config.public flag, a pattern far cleaner than maintaining exclusion lists in Express middleware.

Request Logging Middleware

// Express logging middleware (e.g., morgan)
const morgan = require('morgan');
app.use(morgan('combined'));
// Fastify built-in logging — no middleware needed
const fastify = require('fastify')({
  logger: {
    level: 'info',
    // Automatically logs every request with method, url, statusCode, and responseTime
    serializers: {
      req(request) {
        return {
          method: request.method,
          url: request.url,
          host: request.headers.host,
          userAgent: request.headers['user-agent']
        };
      }
    }
  }
});

// Custom log hook for additional context
fastify.addHook('onResponse', async (request, reply) => {
  if (reply.statusCode >= 400) {
    request.log.warn({ 
      body: request.body,
      params: request.params,
      query: request.query
    }, 'Request resulted in client error');
  }
});

Fastify uses Pino for logging by default, providing structured JSON logging at extremely low overhead. There's no need for third-party logging middleware — the framework instruments every phase of the request lifecycle automatically.

Step 4: Implementing Schema Validation

One of the most powerful features you gain during migration is automatic schema validation. Fastify uses JSON Schema (or Fluent Schema, TypeBox, or Zod) to validate request inputs and serialize response outputs. This eliminates entire swaths of manual validation code.

// Before: Express route with manual body validation
router.post('/api/users', async (req, res, next) => {
  const { name, email, age } = req.body;
  
  if (!name || typeof name !== 'string' || name.length < 2) {
    return res.status(400).json({ error: 'Invalid name' });
  }
  if (!email || !/\S+@\S+\.\S+/.test(email)) {
    return res.status(400).json({ error: 'Invalid email' });
  }
  if (age !== undefined && (typeof age !== 'number' || age < 0 || age > 150)) {
    return res.status(400).json({ error: 'Invalid age' });
  }

  try {
    const user = await createUser({ name, email, age });
    res.status(201).json({ data: user });
  } catch (err) {
    next(err);
  }
});
// After: Fastify route with JSON Schema validation
const createUserSchema = {
  body: {
    type: 'object',
    required: ['name', 'email'],
    additionalProperties: false,
    properties: {
      name: { type: 'string', minLength: 2, maxLength: 100 },
      email: { type: 'string', format: 'email' },
      age: { type: 'integer', minimum: 0, maximum: 150 }
    }
  },
  response: {
    201: {
      type: 'object',
      properties: {
        data: {
          type: 'object',
          properties: {
            id: { type: 'string' },
            name: { type: 'string' },
            email: { type: 'string' },
            age: { type: 'integer' }
          }
        }
      }
    }
  }
};

fastify.post('/api/users', {
  schema: createUserSchema,
  handler: async (request, reply) => {
    const user = await createUser(request.body);
    return reply.code(201).send({ data: user });
  }
});

The Fastify version reduces ~20 lines of validation code to a declarative schema. Fastify automatically returns detailed 400 errors with exact field-level validation messages when input doesn't match the schema. This also guarantees that request.body matches the expected shape inside the handler — a guarantee Express cannot provide.

Step 5: Error Handling Migration

Express error handling relies on four-argument middleware (err, req, res, next) and the next(err) pattern. Fastify provides a cleaner, more predictable error handling system with setErrorHandler and structured error objects.

// Express error handler
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: err.message || 'Internal Server Error',
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
});
// Fastify error handler
fastify.setErrorHandler(async (error, request, reply) => {
  // Fastify errors (validation, not found, etc.) have a statusCode property
  const statusCode = error.statusCode || 500;

  // Log errors with full context
  request.log.error({
    err: error,
    requestId: request.id,
    url: request.url,
    method: request.method
  }, 'Request error');

  // Return structured error response
  return reply.code(statusCode).send({
    error: statusCode >= 500 ? 'Internal Server Error' : error.message,
    statusCode,
    // Include validation details for 400 errors
    ...(error.validation && { validation: error.validation }),
    // Include stack trace only in development
    ...(process.env.NODE_ENV === 'development' && { stack: error.stack })
  });
});

// Custom errors can be thrown anywhere and caught here
class NotFoundError extends Error {
  constructor(message = 'Resource not found') {
    super(message);
    this.statusCode = 404;
  }
}

fastify.get('/api/products/:id', async (request, reply) => {
  const product = await findProduct(request.params.id);
  if (!product) {
    throw new NotFoundError('Product not found');
  }
  return { data: product };
});

Fastify's setErrorHandler is a single centralized handler that catches all errors — including those thrown from async handlers, validation failures, and reply.callNotFound() errors. There's no need for next(err) patterns or four-argument function signatures. Simply throw or return an error, and the handler catches it.

Step 6: Converting Static File Serving and Views

Express uses express.static() for static files and separate view engines. Fastify provides official plugins for both.

// Express static files
app.use('/public', express.static('public'));
app.use(express.static('assets'));

// Express view engine
app.set('view engine', 'ejs');
app.set('views', './views');
app.get('/profile', (req, res) => {
  res.render('profile', { user: req.user });
});
// Fastify equivalent
const fastifyStatic = require('@fastify/static');
const fastifyView = require('@fastify/view');
const path = require('path');

// Register static file serving
fastify.register(fastifyStatic, {
  root: path.join(__dirname, 'public'),
  prefix: '/public/',
  list: { format: 'html' }
});

// Register another static directory
fastify.register(fastifyStatic, {
  root: path.join(__dirname, 'assets'),
  prefix: '/assets/',
  decorateReply: false // Avoid duplicate decoration
});

// Register view engine
fastify.register(fastifyView, {
  engine: { ejs: require('ejs') },
  root: path.join(__dirname, 'views'),
  viewExt: 'ejs',
  includeViewExtension: true
});

fastify.get('/profile', async (request, reply) => {
  return reply.view('profile', { user: request.user });
});

Step 7: Incremental Migration with fastify-express

A full rewrite is rarely feasible for production applications. Fastify provides an official compatibility layer called @fastify/express that allows you to run Express middleware and even entire Express route handlers inside a Fastify server. This enables incremental migration where you move one route at a time while the rest of the application continues functioning.

npm install @fastify/express
const fastify = require('fastify')({ logger: true });
const expressPlugin = require('@fastify/express');

// Register the Express compatibility plugin
fastify.register(expressPlugin);

// Continue using your existing Express app as-is
const expressApp = require('./legacy-express-app');

// Mount the entire Express app under a prefix
fastify.register((instance, opts, done) => {
  instance.use('/legacy', expressApp);
  done();
});

// Add new Fastify-native routes alongside Express routes
fastify.get('/api/v2/users', {
  schema: { /* Fastify schema */ },
  handler: async (request, reply) => {
    const users = await fetchUsers();
    return { data: users };
  }
});

// Start the hybrid server
fastify.listen({ port: 3000 }, (err) => {
  if (err) throw err;
  console.log('Hybrid Express/Fastify server running');
});

This approach lets you migrate endpoints one by one. As you convert routes to Fastify-native handlers, you simply remove them from the Express app. The compatibility layer handles all the (req, res) to (request, reply) conversion internally.

Step 8: Testing the Migrated Application

Fastify's testability is a significant upgrade. The framework provides fastify.inject() for lightweight integration tests that don't require opening actual network ports. This makes tests dramatically faster and more reliable than Express tests that typically spawn HTTP servers.

// Express testing approach (using supertest or similar)
const request = require('supertest');
const app = require('./app');

describe('GET /api/users', () => {
  it('returns users list', (done) => {
    request(app)
      .get('/api/users')
      .expect(200)
      .expect('Content-Type', /json/)
      .end((err, res) => {
        if (err) return done(err);
        expect(res.body.data).toBeInstanceOf(Array);
        done();
      });
  });
});
// Fastify testing with inject()
const buildApp = require('./app'); // Exports a fastify() instance builder

describe('GET /api/users', () => {
  let app;

  beforeAll(async () => {
    app = await buildApp(); // Build but don't listen
  });

  afterAll(async () => {
    await app.close();
  });

  it('returns users list', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/users',
      headers: { authorization: 'Bearer valid-token' }
    });

    expect(response.statusCode).toBe(200);
    expect(response.headers['content-type']).toContain('application/json');
    
    const body = JSON.parse(response.payload);
    expect(body.data).toBeInstanceOf(Array);
  });

  it('rejects invalid query parameters automatically', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/users?limit=invalid'
    });

    expect(response.statusCode).toBe(400);
    const body = JSON.parse(response.payload);
    expect(body.validation).toBeDefined();
  });
});

The inject() method simulates HTTP requests without binding to a port, making tests run in milliseconds rather than seconds. Schema validation errors can be tested precisely, verifying that Fastify's automatic validation catches edge cases correctly.

Best Practices for Migration

Handling Common Migration Pitfalls

Pitfall 1: Response Already Sent Errors

In Express, calling res.json() multiple times or after next() causes "Cannot set headers after they are sent" errors. Fastify eliminates this by design — reply.send() is idempotent and safe to call multiple times (only the first call takes effect), and returning from async handlers prevents accidental double-responses.

// Express: Dangerous - may cause header errors
router.get('/data', async (req, res, next) => {
  const data = await fetchData();
  res.json(data);
  // If fetchData throws, next(err) may also try to send a response
});

// Fastify: Safe - reply.send() is idempotent
fastify.get('/data', async (request, reply) => {
  const data = await fetchData();
  return reply.send(data);
  // If fetchData throws, the error handler catches it — reply.send() never runs
});

Pitfall 2: Plugin Loading Order

Fastify plugins load in the order they are registered, and encapsulated contexts create isolated scopes. This differs from Express where middleware order is determined solely by app.use() calls. Always register utility plugins (decorators, authentication) before route plugins to ensure hooks are available when routes are defined.

// Correct registration order
async function buildApp() {
  const fastify = require('fastify')({ logger: true });

  // 1. Core infrastructure first
  await fastify.register(require('./plugins/database'));
  await fastify.register(require('./plugins/auth'));
  await fastify.register(require('./plugins/rate-limit'));

  // 2. Route plugins second
  await fastify.register(require('./routes/users'));
  await fastify.register(require('./routes/products'));

  // 3. Error handler last
  fastify.setErrorHandler(require('./error-handler'));

  return fastify;
}

Conclusion

Migrating from Express to Fastify is not merely a performance upgrade — it's an investment in your application's long-term reliability and maintainability. The structured plugin system, automatic schema validation, and rich lifecycle hooks replace ad-hoc middleware patterns with predictable, testable abstractions. By following the incremental approach with @fastify/express, you can ship the migration gradually, gaining schema validation and structured logging benefits early while moving route handlers at your own pace.

The key insight is that Fastify does not ask you to abandon everything Express taught you — it refines those patterns into a more disciplined framework. Middleware becomes hooks and plugins. Manual validation becomes declarative schemas. Callback-based error handling becomes a centralized async error handler. Each step in this guide represents a small, concrete improvement that compounds into a significantly more robust application. Start with a single route, add a schema, replace one middleware with a hook, and observe how the Fastify way gradually makes your codebase simpler, faster, and more self-documenting.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles