Why We Swapped Express for Fastify: A Six-Month Production Post-Mortem

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Moving Past the Express Default

Express.js has been the backbone of the Node.js ecosystem for over a decade. It was my go-to for every project, from weekend hacks to large-scale enterprise services. However, six months ago, our team hit a wall. We were managing a microservice handling 15,000 requests per second, and the cracks were starting to show. Despite aggressive caching and vertical scaling, our p99 latency frequently spiked above 500ms, and CPU usage sat at a constant, uncomfortable 85%.

Performance wasn’t our only headache; we were also battling “Type Drift.” Even with TypeScript, our Express request bodies were essentially any until we manually validated them. We spent more time writing repetitive boilerplate than building actual features. That struggle led us to migrate the entire stack to Fastify. After half a year in production, the results weren’t just about raw speed—they fundamentally changed how we build APIs.

The Real-World Bottleneck: Middleware and Manual Validation

The primary issue wasn’t just execution speed. The real killer was the overhead of the middleware pattern and the lack of native schema enforcement. In a typical Express app, validation is an afterthought. You usually pull in a library like Joi or Yup, run it as middleware, and pray your TypeScript interfaces stay in sync with those validators.

As our codebase grew, three specific problems emerged:

  • Serialization Overhead: Standard JSON.stringify() is surprisingly heavy. On large payloads (100KB+), it would block the event loop for several milliseconds during high-concurrency peaks.
  • Memory Bloat: Express lacks a built-in lifecycle management system. This led to messy singleton patterns for database connections that were difficult to clean up during deployments.
  • Validation Gaps: Developers occasionally forgot to apply validation middleware to new routes. This resulted in runtime crashes that TypeScript couldn’t catch.

Why Express Struggles with Modern Demands

Express belongs to an older era of Node.js. Its routing logic relies on a linear scan of middleware. This works fine for five routes, but it becomes a noticeable drag when you have fifty. Furthermore, Express is unopinionated to a fault. It doesn’t care how you validate data, leading to inconsistent patterns across different teams.

Node.js has evolved, but the Express core has stayed largely static. Modern high-performance APIs benefit from ahead-of-time (AOT) optimizations. Fastify does this by pre-compiling JSON schemas into highly optimized validation functions—something Express simply wasn’t built to handle.

Evaluating the Contenders

Before committing to the migration, we benchmarked three main frameworks:

  1. NestJS: A robust framework, but it felt too heavy for this specific microservice. While it can use Fastify under the hood, the extra abstraction layer wasn’t worth the complexity for our needs.
  2. Koa: It offers great middleware control, but it still requires manual assembly for validation and OpenAPI documentation.
  3. Fastify: It promised significantly higher throughput and featured a built-in plugin system with schema validation via Ajv.

Fastify won because it treats schemas as core features. It uses fast-json-stringify to pre-compile response schemas, which is often 2x faster than the standard library. For our high-traffic endpoints, this was a massive win.

The Schema-First Shift

This is where the magic happens. Instead of writing code and then trying to document it, Fastify encourages you to define the shape of your data upfront. By using @sinclair/typebox, we define a single schema that acts as both the runtime validator and the TypeScript type provider.

import { Type } from '@sinclair/typebox';
import Fastify from 'fastify';

const server = Fastify();

const UserSchema = Type.Object({
  id: Type.String(),
  name: Type.String(),
  email: Type.String({ format: 'email' }),
});

// The type is inferred automatically—no manual interface needed
type User = typeof UserSchema.static;

server.post<{ Body: User }>('/users', {
  schema: {
    body: UserSchema,
    response: {
      201: UserSchema,
    },
  },
}, async (request, reply) => {
  const { name, email } = request.body;
  return { id: '1', name, email };
});

This approach killed our “Type Drift” problem. If we change the schema, the TypeScript types update automatically. Even better, our Swagger documentation stays perfectly in sync without extra effort.

Encapsulation with the Plugin System

Managing global state in Express—like database pools or shared config—usually ends in a mess of imports. Fastify solves this with its register API and the fastify-plugin (fp) wrapper. This allows you to create encapsulated modules that won’t accidentally leak into other parts of your app.

import fp from 'fastify-plugin';

export default fp(async (fastify, opts) => {
  const db = await createDbConnection(opts.uri);
  
  // Decorate makes 'db' available on the fastify instance
  fastify.decorate('db', db);

  fastify.addHook('onClose', async (instance) => {
    await instance.db.close();
  });
});

Testing became significantly easier. We can now spin up a Fastify instance, register only the specific plugins needed for a test, and run it with zero side effects from the rest of the application.

Three Non-Negotiable Production Optimizations

Moving to Fastify is about more than just changing a library; it’s about adopting better production habits. Here are three optimizations we now use in every service:

1. High-Speed Logging with Pino

Fastify comes with Pino, which is roughly 10x faster than Winston. Unlike other loggers, Pino logs in JSON by default and has a negligible CPU footprint. In high-traffic environments, standard console logs can actually block the event loop. Pino avoids this entirely.

2. Reliable Graceful Shutdowns

Processes shouldn’t just vanish. When a SIGTERM signal hits—common during Kubernetes rolling updates—you must close database connections and finish active requests. Fastify’s hook system makes this trivial.

const start = async () => {
  try {
    await server.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

['SIGINT', 'SIGTERM'].forEach((signal) => {
  process.on(signal, async () => {
    server.log.info(`Received ${signal}, shutting down...`);
    await server.close();
    process.exit(0);
  });
});

3. Standardized Error Handling

Express error handling is often a chaotic mix of try-catch blocks. Fastify provides a centralized setErrorHandler. This ensures every error follows a standard format and prevents sensitive stack traces from leaking to your users.

The Final Verdict

The numbers speak for themselves. After the migration, our average latency dropped by 30%, and our memory footprint decreased by 25%. However, the real victory was the developer experience. The combination of TypeScript, TypeBox, and strict schema validation meant we caught bugs during development rather than in 2 AM production logs.

If you are building a simple CRUD app, Express is still a fine choice. But if you need to scale, handle complex data, or maintain high reliability, Fastify is the superior tool. It forces you to write better code by default. Your future self—and your Ops team—will thank you.

Share: