DEV Community

DevForge Templates
DevForge Templates

Posted on

I Benchmarked Fastify 5 vs Express 4 on the Same API — Here Are the Numbers

Express has been the default Node.js framework for over a decade. Most tutorials use it, most teams know it, and it works fine for a lot of things. But "works fine" and "performs well" are different conversations.

I built the same API twice -- once with Express 4.21 and once with Fastify 5.2 -- and ran them through identical benchmarks. The gap was larger than I expected.

The Setup

Both APIs implement the same 10 routes: full CRUD for users, products, and orders, plus a health check. Same Prisma 7 ORM, same PostgreSQL 16 database, same seed data (1,000 users, 5,000 products, 10,000 orders). Same machine: M2 MacBook Pro, 16GB RAM, Node.js 22.

The routes include joins (orders with user + product data), pagination, filtering, and JSON serialization of nested objects -- the kind of work a real API does.

Benchmarks ran with autocannon -- 100 concurrent connections, 30-second duration, repeated 5 times per endpoint. Numbers below are averages across all 10 routes.

The Numbers

Metric Express 4.21 Fastify 5.2 Difference
Requests/sec 2,140 15,320 7.2x
Latency p50 41 ms 5.8 ms 7.1x faster
Latency p95 89 ms 12.4 ms 7.2x faster
Latency p99 156 ms 18.7 ms 8.3x faster
Memory (RSS) 142 MB 87 MB 39% less
Startup time 340 ms 185 ms 1.8x faster

The throughput gap widened on routes returning large JSON payloads (list endpoints with nested objects). On the GET /orders?include=user,product route specifically, Fastify hit 12,800 req/s vs Express at 1,640 req/s -- nearly 8x.

On simple routes returning small payloads (health check, single user by ID), the gap narrowed to about 4-5x. Still significant, but the serialization advantage matters most when you're shipping a lot of JSON.

Why Fastify Is Faster

The 7x difference isn't just "Fastify is newer." There are specific architectural decisions that compound:

1. Schema-based serialization with fast-json-stringify

This is the biggest factor. Express uses JSON.stringify() -- the built-in V8 implementation that figures out the shape of your object at runtime on every call. Fastify uses fast-json-stringify, which takes a JSON Schema and compiles a specialized serializer function ahead of time.

When you define a response schema, Fastify generates something like:

// Compiled serializer (simplified)
function serialize(obj) {
  return '{"id":"' + obj.id + '","name":"' + obj.name + '","email":"' + obj.email + '"}'
}
Enter fullscreen mode Exit fullscreen mode

No property enumeration, no type checking at runtime, no toJSON() calls. Just string concatenation in the known shape. On nested objects with arrays, this alone accounts for 3-4x of the speed difference.

2. Radix tree router vs linear middleware chain

Express matches routes by walking through every registered middleware and route in order until something matches. Fastify uses find-my-way, a radix tree router that finds the handler in O(log n) time instead of O(n). With 10 routes you barely notice. With 100+ routes, it matters.

3. Plugin encapsulation vs global middleware

Express middleware runs on every request unless you scope it manually with router.use(). Fastify's plugin system creates encapsulated contexts -- middleware registered in a plugin only runs for routes in that plugin. Less work per request, and the framework knows at startup time which middleware applies to which routes.

4. Request parsing

Fastify validates incoming request bodies against a JSON Schema at parse time using ajv. Express typically validates later in the handler (if at all), meaning malformed requests get further through the pipeline before being rejected.

Code Comparison

Here's the same route in both frameworks -- list orders with filtering and pagination:

Express:

import { Router } from "express";
import { z } from "zod";
import { prisma } from "../db";

const router = Router();

const querySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  status: z.enum(["pending", "shipped", "delivered"]).optional(),
});

router.get("/orders", async (req, res) => {
  const parsed = querySchema.safeParse(req.query);
  if (!parsed.success) {
    return res.status(400).json({ error: parsed.error.flatten() });
  }

  const { page, limit, status } = parsed.data;
  const where = status ? { status } : {};

  const [orders, total] = await Promise.all([
    prisma.order.findMany({
      where,
      include: { user: true, product: true },
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: "desc" },
    }),
    prisma.order.count({ where }),
  ]);

  res.json({ orders, total, page, pages: Math.ceil(total / limit) });
});
Enter fullscreen mode Exit fullscreen mode

Fastify:

import { FastifyInstance } from "fastify";
import { z } from "zod";

const querySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  status: z.enum(["pending", "shipped", "delivered"]).optional(),
});

export default async function orderRoutes(fastify: FastifyInstance) {
  fastify.get("/orders", {
    schema: {
      querystring: {
        type: "object",
        properties: {
          page: { type: "integer", minimum: 1, default: 1 },
          limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
          status: { type: "string", enum: ["pending", "shipped", "delivered"] },
        },
      },
      response: {
        200: {
          type: "object",
          properties: {
            orders: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  id: { type: "string" },
                  status: { type: "string" },
                  total: { type: "number" },
                  user: {
                    type: "object",
                    properties: {
                      id: { type: "string" },
                      name: { type: "string" },
                      email: { type: "string" },
                    },
                  },
                  product: {
                    type: "object",
                    properties: {
                      id: { type: "string" },
                      name: { type: "string" },
                      price: { type: "number" },
                    },
                  },
                },
              },
            },
            total: { type: "integer" },
            page: { type: "integer" },
            pages: { type: "integer" },
          },
        },
      },
    },
  }, async (request) => {
    const { page, limit, status } = request.query as z.infer<typeof querySchema>;
    const where = status ? { status } : {};

    const [orders, total] = await Promise.all([
      fastify.prisma.order.findMany({
        where,
        include: { user: true, product: true },
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: "desc" },
      }),
      fastify.prisma.order.count({ where }),
    ]);

    return { orders, total, page, pages: Math.ceil(total / limit) };
  });
}
Enter fullscreen mode Exit fullscreen mode

Yes, the Fastify version is longer. That response schema is the price you pay for the serialization speed. But you get two things for free from that schema: automatic input validation (no manual safeParse needed -- Fastify rejects invalid queries before your handler runs) and auto-generated Swagger documentation via @fastify/swagger.

Zod + Fastify: Best of Both Worlds

If writing JSON Schema by hand feels tedious, fastify-type-provider-zod lets you use Zod schemas directly:

import { ZodTypeProvider } from "fastify-type-provider-zod";

fastify.withTypeProvider<ZodTypeProvider>().get("/orders", {
  schema: {
    querystring: querySchema,
    response: { 200: orderListSchema },
  },
}, async (request) => {
  // request.query is fully typed from the Zod schema
  const { page, limit, status } = request.query;
  // ...
});
Enter fullscreen mode Exit fullscreen mode

You write Zod schemas once. The plugin converts them to JSON Schema for validation and serialization, and TypeScript infers the types. One source of truth for validation, serialization, types, and API docs.

Migration Path

You don't have to rewrite everything. @fastify/express lets you mount Express middleware and routers inside a Fastify app:

import Fastify from "fastify";
import expressPlugin from "@fastify/express";
import legacyRouter from "./legacy-express-routes";

const fastify = Fastify();

await fastify.register(expressPlugin);
fastify.use("/api/v1", legacyRouter);

// New routes get the full Fastify treatment
fastify.get("/api/v2/orders", { schema: { /* ... */ } }, handler);
Enter fullscreen mode Exit fullscreen mode

This lets you migrate route by route. Old Express routes work through the compatibility layer (slower, but functional). New routes use native Fastify. Over time, you convert the old ones.

When Express Is Still the Right Choice

Not everything needs 15,000 req/s. Express makes sense when:

  • Your team knows Express and doesn't know Fastify. Developer velocity usually matters more than request throughput. The learning curve is real -- Fastify's plugin system takes time to internalize.
  • You need a specific middleware. Express has thousands of battle-tested middleware packages. Fastify's ecosystem is growing fast but it's not at parity yet. Check that the middleware you depend on has a Fastify equivalent before committing.
  • Your bottleneck is the database, not the framework. If every request spends 200ms in PostgreSQL, cutting framework overhead from 40ms to 6ms is a 17% improvement, not a 7x improvement. Profile before you optimize.
  • It's a small internal tool. If the app serves 50 requests per minute, the framework choice is irrelevant. Use what ships faster.

The Honest Take

Don't rewrite working Express applications. The migration cost rarely justifies the performance gain unless you're actually hitting throughput limits. I've seen teams spend weeks migrating to Fastify and gain nothing because their real bottleneck was a slow database query.

But for new projects? Fastify is the better default. The schema-first approach catches bugs earlier, the plugin system enforces better code organization, and you get Swagger docs without maintaining a separate OpenAPI file. The performance is a bonus on top of a genuinely better developer experience -- once you get past the initial learning curve.

The benchmarks prove Fastify is faster. Whether that speed matters for your specific use case is a different question. Measure your actual bottlenecks before making architectural decisions based on synthetic benchmarks -- including mine.

Top comments (0)