There's a meeting that happens in almost every Node.js shop eventually. Someone runs an autocannon benchmark, screenshots a number like "Fastify: 76k req/s, Express: 38k req/s", drops it in Slack, and asks the question: should we migrate?
The honest answer is "it depends, and probably not for the reason in that screenshot." Throughput numbers from a hello-world endpoint are the least interesting thing about these two frameworks. The interesting parts are how they think about middleware, how they handle errors, what their plugin systems are actually for, and what happens when your codebase grows past 50 routes and 5 engineers.
Let's go through it properly.
A short history, because it explains everything
Express was released by TJ Holowaychuk in 2010. It's the original Node.js web framework - almost every framework you've ever heard of (Koa, NestJS, Hapi, even Fastify in spirit) is defined either by extending Express or by reacting to it. It became the de facto standard so completely that "Node.js backend" and "Express app" were synonyms for about a decade.
For most of that decade, Express barely changed. Version 4 shipped in April 2014. Version 5 shipped on October 15, 2024 - that's a ten-and-a-half year gap on the same major version. The community kept moving (async/await, ESM, TypeScript), but Express stayed pinned to a callback-era design that predates promises landing in V8.
Fastify is the reaction. Matteo Collina and Tomas Della Vedova started it at NearForm in 2016 - v0.1.0 landed on October 17, 2016, which makes it almost exactly 8 years younger than Express. The pitch was simple: take everything we've learned from running large Node.js services in production, and design a framework around it from scratch. JSON Schema everywhere. Plugin encapsulation. A serious logger by default. Async/await as the native model, not a retrofit. Fastify v5 shipped in September 2024 - a few weeks before Express 5, which is a nice piece of historical symmetry.
Knowing the gap matters because almost every design difference between the two falls out of it. Express is what you'd write if you were inventing Node web frameworks in 2010. Fastify is what you'd write if you started over in 2016 with five years of production Node scars.
The performance gap - what's real, what's noise
You will see wildly different numbers depending on whose benchmark you read. Some put Fastify at 2x Express. Some put it at 5x. Some at 1.3x. They're all "right" within their own setup, which is why I won't quote one as gospel. The honest range from public benchmarks on Node.js 20+ with a JSON-returning endpoint is roughly:
- Express 4: ~10k-40k req/s depending on hardware.
- Express 5: similar to Express 4, sometimes slightly slower because of the new async error handling layer.
- Fastify 4/5: ~40k-115k req/s on the same hardware, typically 2-3x Express.
Two things actually drive that gap, and neither of them is "Fastify is written better."
1. Schema-based JSON serialization
This is the biggest one. Express serializes responses with JSON.stringify. Fastify serializes them with fast-json-stringify, which compiles your response schema into a purpose-built function at startup. The compiled function knows exactly which keys exist, what types they are, and doesn't have to do any of the introspection JSON.stringify does at runtime.
fastify.get('/users/:id', {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' }
}
}
}
}
}, async (req) => {
return await users.findById(req.params.id)
})
That schema isn't documentation. At boot, Fastify compiles it into something that looks roughly like return '{"id":"' + obj.id + '","email":"' + obj.email + '",...}' - straight string concatenation, no introspection, no extra properties leaking out. In micro-benchmarks of serialization alone, this is typically 2-5x faster than JSON.stringify. It also doubles as a security feature: fields not in the schema are silently dropped, so you can't accidentally leak a password hash by returning the whole user object.
Express has no equivalent. You can install fast-json-stringify yourself, but you're now wiring schemas, compilers, and per-route serializers by hand - which is exactly what Fastify already did for you.
2. The middleware vs hook model
Express's middleware system is a single linear chain. Every request walks through every middleware until something calls res.send() or next(err). The chain itself is a linked list of closures, and each middleware function call has its own (req, res, next) overhead. This is fine - until you have 30 middlewares.
Fastify replaces that with a hook lifecycle: onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse. Each hook is a named, well-defined phase. Internally, Fastify uses find-my-way - a radix-tree router that matches routes in O(k) where k is the URL length, not O(n) where n is the route count. Express uses a regex-per-route walk. On 5 routes you'll never notice. On 500, you will.
Ecosystem maturity - Express still wins, and it's not close
Be honest about this one. Express has been the default for 15 years. That means:
- Every tutorial, course, and Stack Overflow answer from 2011 onwards uses Express. When you onboard a junior engineer, they already know it.
- Every middleware that has ever existed for Node has an Express version:
passport,morgan,helmet,connect-flash, every OAuth strategy, every CSRF library, every random session store for every random Redis-alike. - Every observability vendor (Datadog, New Relic, Sentry, Honeycomb) has first-class Express auto-instrumentation. Fastify support is universal at this point too, but Express is often the path of least resistance - the agent just works.
- Every Node-focused boilerplate, template, and
create-*tool assumes Express unless it says otherwise.
Fastify's ecosystem is healthy and well-maintained - the core team ships a coherent set of official plugins under the @fastify/* scope (@fastify/jwt, @fastify/cors, @fastify/swagger, @fastify/rate-limit, @fastify/static, @fastify/multipart), and they tend to be higher quality than the equivalent Express middleware grab-bag. But the long tail is thinner. If you Google "fastify + $obscure_thing", you'll sometimes hit a dead end. If you Google "express + $obscure_thing", you'll find seven blog posts and an npm package from 2017.
This cuts both ways. Express's long tail includes a lot of unmaintained packages with known CVEs. Fastify's smaller tail is generally healthier per capita. Pick your trade.
About connect-style middleware
Fastify ships @fastify/middie, which lets you use Express-style (req, res, next) middleware inside a Fastify app. It works. It also throws away most of the encapsulation and lifecycle benefits of Fastify, so the official guidance is "use it as an escape hatch for libraries you can't replace, not as your default model."
Developer experience - the part nobody benchmarks
This is where I think the comparison actually gets interesting, because it's not a number you can put in a screenshot.
Express: nothing, then everything
A fresh Express app is the smallest possible thing. You install one package and write five lines. That's the magic - and also the trap. As soon as you need anything beyond "echo a string", you're picking, installing, and wiring middleware yourself:
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import compression from 'compression'
import morgan from 'morgan'
import { json, urlencoded } from 'express'
import pino from 'pino-http'
import { z } from 'zod'
const app = express()
app.use(helmet())
app.use(cors({ origin: process.env.ALLOWED_ORIGIN }))
app.use(compression())
app.use(json({ limit: '1mb' }))
app.use(urlencoded({ extended: true }))
app.use(pino())
app.use(morgan('combined'))
// ... and now you write your own validation middleware that wraps zod
// ... and your own error handler
// ... and your own request-id middleware
// ... and your own graceful shutdown
Each one is a separate package with separate maintenance, separate docs, separate update cadence, and separate opinions about how req.user should be attached. None of them know about each other. You wire it all yourself. After a year, every Express app in your company looks slightly different because every engineer made slightly different choices at scaffold time.
This is the freedom-vs-cohesion trade Express makes. It's a feature for prototypes and a bug for teams.
Fastify: opinionated where it counts
Fastify ships with some non-negotiables baked in:
- Pino logger by default - production-grade JSON logging with a child logger per request, sub-microsecond log calls. (Pino was written by the same Matteo Collina, which is not an accident.)
- JSON Schema validation for body, query, params, and headers - declarative, fast, and the same schemas drive your Swagger/OpenAPI docs if you plug in
@fastify/swagger. - Lifecycle hooks instead of middleware ordering - phases are named, ordering is explicit.
- Request ID out of the box.
reply.send()returns - andasynchandlers can justreturnthe response object, nores.sendceremony.
import Fastify from 'fastify'
const app = Fastify({
logger: true,
bodyLimit: 1024 * 1024,
trustProxy: true
})
await app.register(import('@fastify/helmet'))
await app.register(import('@fastify/cors'), { origin: process.env.ALLOWED_ORIGIN })
await app.register(import('@fastify/compress'))
await app.register(import('@fastify/swagger'))
Same surface area, smaller blast radius, one set of opinions. The cost is that those opinions are sometimes ones you'd rather not have - if you hate Pino's output format, you'll either fight it or write a transport.
The error-handling story has changed
For most of its life, Express had one big DX wart: async errors didn't propagate. This was real production-breaking behaviour:
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id) // throws? request hangs forever
res.json(user)
})
The fix was either try/catch in every handler, or wrapping every route with express-async-handler or express-async-errors, or migrating to Express 5. Plenty of large Express codebases still ship the try/catch boilerplate because the migration is non-trivial.
Express 5 fixes this - rejected promises now flow to the error handler - but the upgrade is gated on a non-trivial set of breaking changes (path-to-regexp v6, dropped Node versions, several middleware behaviour changes). Most large Express 4 codebases I've seen are still on 4.
Fastify never had this problem. async handlers have worked correctly since v1.
Plugins and encapsulation - Fastify's actual best idea
If you skim Fastify's docs, the plugin system looks like Express middleware with a different name. It isn't. The plugin system is Fastify's most important design decision, and once you understand it, going back to Express feels like programming with all your variables in global scope.
Fastify's plugin loader is a separate library called avvio. Every register() call creates a new encapsulated context - a child of the parent. Decorators, hooks, and plugins added inside that context are invisible to siblings and to the parent. To opt out (i.e., to add a decorator that should be visible everywhere), you wrap your plugin with fastify-plugin.
What that buys you in practice:
import Fastify from 'fastify'
const app = Fastify()
// Public routes - no auth
app.register(async (publicScope) => {
publicScope.get('/health', async () => ({ ok: true }))
publicScope.get('/login', loginHandler)
})
// Admin routes - auth required, with their own rate-limiter
app.register(async (adminScope) => {
adminScope.addHook('preHandler', requireAdmin)
await adminScope.register(import('@fastify/rate-limit'), { max: 10 })
adminScope.get('/admin/users', listUsers)
adminScope.delete('/admin/users/:id', deleteUser)
})
The requireAdmin hook and the rate-limiter only apply to routes inside the admin scope. Public routes are completely unaffected. There is no global state to forget about, no "did I app.use(adminAuth) before or after this route?" archaeology. If you've ever debugged Express middleware ordering, you know exactly why this matters.
You can also do the inverse - share a database connection across the whole app:
import fp from 'fastify-plugin'
export default fp(async (fastify) => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
fastify.decorate('db', pool)
fastify.addHook('onClose', async () => pool.end())
})
Now fastify.db is available in every plugin and every route, with one well-defined lifecycle and one shutdown hook. Compare that to the typical Express pattern of importing a singleton from ./db.js and hoping everyone closes it correctly on SIGTERM.

Scaling trade-offs
Scaling here means two different things: scaling the runtime (more requests per second per CPU), and scaling the codebase (more engineers, more routes, more years).
Runtime scaling
Both frameworks scale horizontally the same way - fork with cluster, run behind a load balancer, you know the drill. The interesting questions are at the single-process level.
Memory. Fastify's schema compilation costs you upfront. Each schema becomes a compiled AJV validator and a compiled fast-json-stringify serializer. For an app with hundreds of routes and large schemas, this can push baseline memory up by tens of MB and slow startup by hundreds of milliseconds. The @fastify/ajv-compiler package exists specifically to share AJV instances across encapsulated contexts and reduce that footprint, and AJV v8's standalone mode lets you pre-compile schemas to source files at build time so they don't need to be compiled at boot. Worth knowing if you care about cold-start time in Lambda or Cloud Run.
Event-loop pressure. Both frameworks live on the same event loop, so the same rules apply: don't block it. The difference is that Fastify exposes onResponse hooks and reply lifecycle events that make it much easier to instrument per-request timing, and the built-in Pino logger is non-blocking by design. Express's typical logging stack (morgan writing to stdout synchronously, or winston with a transport that does I/O on the hot path) can become the bottleneck before the framework does.
HTTP/2 and Unix sockets. Fastify supports HTTP/2 in core (still flagged as experimental but stable in practice) and trivially listens on Unix sockets. Express supports both, but you'll wire up the http2 module yourself, and behaviours like req.path differ subtly under HTTP/2.
Codebase scaling
This is where the frameworks diverge most.
Express at 200 routes tends to look like one of three things:
- A single
index.jsthat's 4,000 lines. - A folder of route files imported into
index.js, each using the same globalapp. - A homegrown plugin system someone built on top of Express to fake what Fastify ships out of the box.
Option 3 is more common than you'd expect, and once you've seen a team reinvent register() and encapsulation badly, the appeal of just using Fastify becomes obvious.
Fastify at 200 routes tends to look like a tree of plugins, each owning a feature area, each with its own schemas, hooks, and decorators. The pattern is documented and the tooling cooperates. New engineers can read one plugin file and understand the whole feature without holding the global middleware order in their head.
NestJS as the third option. If you're choosing today and you know you'll be at 200+ routes with 10+ engineers, NestJS - which can run on top of either Express or Fastify as its HTTP layer - is worth considering. It trades raw performance and minimalism for a strongly opinionated module/dependency-injection structure. It's a different conversation, but worth flagging so this comparison doesn't accidentally read as "these are your only two options."
Gotchas nobody mentions until it bites you
A few real-world things that don't show up in framework-comparison posts:
Fastify's plugin async-init can deadlock if you mix await and done callbacks. Plugins can be either async functions or callback-style (fastify, opts, done) => { ... done() }. Mixing them - accepting a done callback in an async function - leaves done un-called and your app hangs at boot with no error. The fix is "pick one style per plugin", but the failure mode is silent.
Fastify's schema validation rejects fields you didn't define. This is usually what you want. It is also, occasionally, why your endpoint returns 400 in production when a mobile client sends a new field your server doesn't know about yet. Set additionalProperties: true deliberately on schemas where you want forward-compatibility.
Express's res.send is overloaded in confusing ways. res.send(404) sends a 404 status with an empty body. res.send('404') sends a 200 with the string body "404". This has caused outages. Fastify's reply.code(404).send() is unambiguous.
Express's path-to-regexp v6 (in Express 5) is stricter than v0.x (in Express 4). Patterns like /users/:id? and unnamed wildcards need to be rewritten. Migration audits frequently miss this until a route silently 404s in production.
Fastify's reply.send() returns the reply, not a promise. New Fastify users sometimes write await reply.send(data) expecting it to wait for the response to flush. It doesn't - send schedules the response and returns synchronously. Use reply.sent if you need to check, or just return data from an async handler.
Express's middleware can mutate req freely; Fastify discourages it. In Express it's normal to do req.user = ... from auth middleware. In Fastify, decorators are the supported way (fastify.decorateRequest('user', null)) and they pre-allocate the slot for V8 hidden-class stability. Skipping the decorator works but eats your performance advantage.
When to pick which
A short, honest decision tree:
Pick Express if:
- You're prototyping and want the smallest possible thing.
- You're building something small (under 30 routes, one or two engineers) that will stay small.
- Your team only knows Express and the system isn't bottlenecked on the framework.
- You have a hard dependency on an Express-only middleware that has no Fastify equivalent.
- You're maintaining a large existing Express codebase where the migration cost outweighs the benefit.
Pick Fastify if:
- You're starting a new service today and you expect it to grow.
- You care about JSON Schema-driven OpenAPI/Swagger from day one.
- Throughput per CPU actually matters (edge services, fan-out gateways, high-RPS APIs).
- You want logging, validation, and serialization to be solved problems instead of three more libraries to choose.
- You expect more than one engineer in the codebase and you want the structure of the app to push them toward consistency.
Pick something else (NestJS, Hono, tRPC) if:
- You want full DI/modules (Nest).
- You want one framework that runs on Node, Bun, Deno, Cloudflare Workers, Lambda (Hono).
- You're building a typed RPC contract end-to-end with a TypeScript frontend (tRPC).
Both Express and Fastify are good choices for production. Most of the time, the question isn't "is the other one faster" - it's "which one's shape matches the shape of the problem we're actually solving, and which one will be less painful to live with in two years." The benchmark screenshot rarely answers that.






