Beyond Express: Why we switched to Fastify (and when you should too)

A deep dive into benchmarks, plugin architecture, and real-world migration lessons from our journey to a more performant Node.js framework.

Introduction

When we built our first API service three years ago, Express.js was the natural choice. As the most popular Node.js framework with extensive documentation and community support, it served us well through our early growth phase. However, as our user base expanded and performance requirements became more demanding, we began experiencing pain points that led us to explore alternatives.

Enter Fastify: a high-performance, low-overhead web framework for Node.js that’s been gaining significant traction. This post details why we made the switch, what we learned during migration, and when you should consider doing the same for your projects.

The Breaking Point: When Express Started Holding Us Back

Before diving into Fastify, let’s examine the specific challenges we faced with Express:

  1. Performance bottlenecks – As our API traffic increased, response times grew despite our optimization efforts
  2. Middleware hell – Complex request-handling pipelines became difficult to maintain
  3. Schema validation – Our DIY validation approach using Joi felt bolted-on rather than integrated
  4. TypeScript support – Express’s TypeScript integration required extra work and still felt incomplete

These challenges became more pronounced as we scaled, ultimately pushing us to investigate alternatives.

The Performance Advantage: Benchmarks Don’t Lie

The first and most compelling reason to consider Fastify is raw performance. During our evaluation, we benchmarked both frameworks in scenarios matching our production environment:

FrameworkRequests/secLatency (avg)Memory Usage
Express8,24612.1ms78MB
Fastify26,8933.7ms52MB

These numbers represent a 3.2x throughput improvement and 67% reduction in average latency. The difference was significant enough to justify deeper investigation.

What drives this performance gap? Fastify’s architectural advantages include:

  • Highly optimized internal routing using a radix tree structure
  • JSON serialization/deserialization via the lightning-fast fast-json-stringify
  • Schema-based optimization that pre-generates optimized functions for validation and serialization

For context, these benchmarks were run on modest hardware similar to our production environment: a 4-core virtual machine with 8GB RAM running Node.js 18.x.

Plugin Architecture: A Better Way to Structure Your Application

Fastify’s plugin system represents a fundamental philosophical difference from Express. While Express relies on middleware and routes configured on a central application object, Fastify embraces a modular, encapsulated approach.

The Plugin Advantage

Fastify plugins are essentially self-contained modules with their own:

  • Routes
  • Hooks
  • Utilities
  • Dependencies

This architecture enforces better separation of concerns and promotes more maintainable code. Here’s a simplified example:

// user-service.js
module.exports = async function userPlugin(fastify, options) {
  // Plugin-scoped database connection
  const db = await connectToUserDb(options.dbUri)
  
  // Register plugin-specific utilities
  fastify.decorate('getUserById', async function(id) {
    return db.users.findOne({ id })
  })
  
  // Register routes
  fastify.get('/users/:id', async (request, reply) => {
    const user = await fastify.getUserById(request.params.id)
    return user
  })
  
  // Clean up when server closes
  fastify.addHook('onClose', async () => await db.close())
}

Each plugin operates in its own encapsulated context, making it easier to:

  1. Reason about code – Dependencies and functionality are co-located
  2. Test components in isolation – Plugins can be tested independently
  3. Manage complex applications – Clear boundaries prevent “spaghetti code”
  4. Reuse functionality – Plugins can be published as npm packages

This architecture naturally pushed us toward a more modular, maintainable codebase.

Schema Validation: Built-in, Not Bolted-on

Perhaps Fastify’s most underrated feature is its built-in schema validation. Using JSON Schema, Fastify validates requests and responses while simultaneously generating optimized serialization functions:

fastify.post('/products', {
  schema: {
    body: {
      type: 'object',
      required: ['name', 'price'],
      properties: {
        name: { type: 'string' },
        price: { type: 'number', minimum: 0 },
        description: { type: 'string' },
        tags: { 
          type: 'array', 
          items: { type: 'string' } 
        }
      }
    },
    response: {
      201: {
        type: 'object',
        properties: {
          id: { type: 'string', format: 'uuid' },
          created: { type: 'string', format: 'date-time' }
        }
      }
    }
  },
  handler: async (request, reply) => {
    const id = await productService.create(request.body)
    reply.code(201)
    return { id, created: new Date().toISOString() }
  }
})

This approach offers multiple benefits:

  1. Automatic validation – Bad requests fail fast with descriptive errors
  2. Self-documenting APIs – Schemas serve as documentation
  3. Performance optimization – Schemas generate optimized functions for validation and serialization
  4. TypeScript integration – Generate types from schemas (or vice versa)

With our previous Express setup, we had cobbled together our own validation using Joi middleware. Moving to Fastify’s integrated approach eliminated redundant code and improved performance.

Real-world Migration: Lessons from the Trenches

Our migration from Express to Fastify took approximately three weeks for a medium-sized API with 60+ endpoints. Here are the key lessons we learned:

What Went Smoothly

  1. Core route handlers – Most route handler logic transferred with minimal changes
  2. HTTP semantics – Basic request/response handling is conceptually similar
  3. Middleware conversion – Many Express middleware have Fastify equivalents or can be adapted

Migration Challenges

  1. Middleware ordering – Fastify’s hooks behave differently from Express middleware
  2. Error handling – Fastify’s error handling required a different approach
  3. Testing – Our test suite needed updates to work with Fastify’s injection system

Migration Strategy That Worked for Us

We adopted a phased approach:

  1. Create a parallel implementation – Built Fastify routes alongside Express
  2. Implement feature flags – Used environment variables to control which implementation handled requests
  3. Migrate one endpoint group at a time – Started with low-traffic, non-critical endpoints
  4. Run both in production – Gradually shifted traffic while monitoring performance
  5. Comprehensive testing – Extensive comparison testing between implementations

This approach allowed us to roll back quickly if issues emerged and gave us confidence throughout the process.

Code Migration Examples

Here’s a concrete example of migrating an endpoint from Express to Fastify:

Express Version:

// Express route
app.get('/api/products/:id', authenticate, async (req, res, next) => {
  try {
    const product = await productService.findById(req.params.id)
    if (!product) {
      return res.status(404).json({ error: 'Product not found' })
    }
    res.json(product)
  } catch (err) {
    next(err)
  }
})

Fastify Version:

// Fastify route
fastify.get('/api/products/:id', {
  preHandler: [fastify.authenticate],
  schema: {
    params: {
      type: 'object',
      required: ['id'],
      properties: {
        id: { type: 'string' }
      }
    },
    response: {
      200: ProductSchema,
      404: {
        type: 'object',
        properties: {
          error: { type: 'string' }
        }
      }
    }
  },
  handler: async (request, reply) => {
    const product = await fastify.productService.findById(request.params.id)
    if (!product) {
      return reply.code(404).send({ error: 'Product not found' })
    }
    return product
  }
})

The Fastify version adds schema validation and is more explicit, but the core logic remains similar.

When Should You Switch to Fastify?

Based on our experience, here are scenarios where a switch makes sense:

Consider Switching When:

  1. You have performance bottlenecks – If your API is struggling under load
  2. Your codebase is becoming unwieldy – Fastify’s plugin system encourages better organization
  3. You’re starting a new project – For greenfield projects, Fastify offers advantages from day one
  4. Schema validation is important – If you need robust input validation
  5. TypeScript is a priority – Fastify has excellent TypeScript support

Maybe Wait If:

  1. Your Express app is small and performant – If it’s not broken, don’t fix it
  2. You rely heavily on Express-specific middleware – Converting complex middleware can be challenging
  3. Your team is unfamiliar with Fastify – Consider the learning curve
  4. You’re in the middle of critical development – Wait for a less busy period

Final Thoughts: Our Experience Six Months Later

Six months post-migration, we’ve seen:

  • 50% reduction in API latency in production environments
  • 30% decrease in server costs due to better resource utilization
  • Improved developer experience with better typing and plugin architecture
  • More robust request validation catching errors earlier

While the migration required significant effort, the performance and maintainability improvements have more than justified the investment. Our team is now more productive, and our service more reliable.

If you’re experiencing growing pains with Express or starting a new Node.js project where performance matters, we strongly recommend giving Fastify serious consideration.


What’s your experience with Fastify or other Express alternatives? I’d love to hear about it in the comments!


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

CAPTCHA ImageChange Image