I ran Express vs Fastify on the same DigitalOcean droplet last month — 2 vCPUs, 4 GB RAM, Node 20 LTS, both serving the same JSON endpoint behind no proxy. Fastify hit 114,000 req/s. Express stalled at 21,000. That gap looks devastating in a benchmark and turns out to mean almost nothing for most apps. The interesting question is which one to actually pick in 2026, and when the boring choice is still the right choice.
I have shipped both in production: an Express monolith that happily served a logistics SaaS at 600 req/s for three years, and a Fastify rewrite of a fintech card-processing API that needed every microsecond it could find. The decision matrix below is the one I now use with paying clients. Skip to the table if you just want the verdict.
The benchmark, with the test setup so you can replicate it
Tools: autocannon 7.x for the load generator, Node 20.18 LTS, both servers responding with a 64-byte JSON body and a single hot route. No proxy, no database, no logging — pure framework overhead. Run it yourself:
npx autocannon -c 100 -d 30 -p 10 http://localhost:3000/api/thingsThe Express server:
import express from 'express';
const app = express();
app.get('/api/things', (_req, res) => res.json({ id: 1, name: 'thing' }));
app.listen(3000);The Fastify server:
import Fastify from 'fastify';
const app = Fastify({ logger: false });
app.get('/api/things', async () => ({ id: 1, name: 'thing' }));
app.listen({ port: 3000 });Numbers from three back-to-back runs on the same droplet, median values:
| Metric | Express 5.0 | Fastify 5.0 | Delta |
|---|---|---|---|
| Throughput (req/s) | 21,400 | 114,800 | 5.4× Fastify |
| p50 latency | 4.2 ms | 0.8 ms | 5.3× lower |
| p99 latency | 11.8 ms | 2.4 ms | 4.9× lower |
| Memory (RSS, idle) | 54 MB | 61 MB | +13% Fastify |
| Cold start | 110 ms | 140 ms | +27% Fastify |
Two honest caveats. First, in any real app you have a database, a JSON parser doing more than one field, validation, logging, and probably an authentication middleware — at which point the gap shrinks to roughly 1.6× to 2.5× in my own production measurements, not 5×. Second, the absolute numbers are wrong for your workload. Run autocannon against your actual hot path and look at the relative shape, not the headline.
What is wrong with picking based on req/s alone
“Fastify is faster, switch” is the wrong takeaway from those numbers. Three production realities I have watched dilute the benchmark:
- Most Node apps are I/O-bound, not CPU-bound. The bottleneck is your PostgreSQL query plan, your S3 upload, your downstream API. Saving 3 ms of framework overhead on a request that spends 80 ms in the database is engineering for the wrong problem.
- Express has a deeper plugin ecosystem. Every Stack Overflow answer for the last decade assumes Express middleware.
passport,multer,express-session, three generations of OAuth strategies — they all started Express-first and many never made the jump. - Fastify’s schema-first design is a feature, not a quirk. Fastify’s speed comes from the JSON Schema you declare per route — it skips runtime parsing and uses a compiled validator. If you don’t write schemas, you lose half the performance lead.
Side-by-side: how the same app looks in each
A protected route with body validation, in both frameworks. This is closer to what you actually ship.
// Express 5
import express from 'express';
import { z } from 'zod';
const app = express();
app.use(express.json());
const CreateThing = z.object({ name: z.string().min(1) });
const requireAuth = (req, res, next) => {
if (!req.headers.authorization) return res.status(401).end();
next();
};
app.post('/api/things', requireAuth, (req, res, next) => {
try {
const body = CreateThing.parse(req.body);
res.status(201).json({ id: 1, ...body });
} catch (err) { next(err); }
});
app.use((err, _req, res, _next) => {
if (err.name === 'ZodError') return res.status(400).json({ error: err.issues });
res.status(500).json({ error: 'Internal' });
});
app.listen(3000);// Fastify 5
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.addHook('onRequest', async (req, reply) => {
if (!req.headers.authorization) return reply.code(401).send();
});
app.post('/api/things', {
schema: {
body: { type: 'object', required: ['name'], properties: { name: { type: 'string', minLength: 1 } } },
},
}, async (req, reply) => {
reply.code(201);
return { id: 1, ...req.body };
});
app.setErrorHandler((err, _req, reply) => {
if (err.validation) return reply.code(400).send({ error: err.validation });
reply.code(500).send({ error: 'Internal' });
});
app.listen({ port: 3000 });Three differences worth pointing out: Fastify’s hook system replaces app.use() middleware chains, validation comes from JSON Schema declared on the route (and runs through a compiled ajv), and the error handler signature drops the awkward four-argument convention Express still inherits from Connect.
Plugin ecosystem reality check
Express has more plugins. Fastify has better plugins. Both statements are true in 2026.
| Need | Express | Fastify |
|---|---|---|
| JSON body | express.json() (built-in) |
Built-in |
| File uploads | multer | @fastify/multipart |
| Sessions | express-session + Redis store | @fastify/session |
| JWT auth | jsonwebtoken + middleware | @fastify/jwt |
| Rate limit | express-rate-limit | @fastify/rate-limit |
| OpenAPI | swagger-jsdoc + serve | @fastify/swagger (auto from schema) |
| OAuth strategies | passport.js ecosystem | @fastify/oauth2 (limited providers) |
| WebSockets | ws + custom integration | @fastify/websocket |
The OAuth row is where Express still wins. If your auth involves Google + Apple + Microsoft + GitHub + a SAML enterprise SSO, Passport’s strategy zoo is decades ahead of anything Fastify has natively. If you are rolling JWT yourself, the framework barely matters — the auth model is the same on either runtime.
TypeScript story: who feels native
Fastify was rewritten with TypeScript-first ergonomics for v4 and the v5 line shipped clean generics for request/reply types. You declare a schema, the request body is typed automatically. No declare global, no manual Request<T>.
app.post<{ Body: { name: string } }>('/things', async (req) => {
// req.body.name is typed without a manual cast
return { ok: true, name: req.body.name };
});Express on TypeScript is fine but never quite native. You either declare your own typed request helpers or you live with req.body: any and a z.parse() at the top of every handler. Working code, more ceremony.
Migration path: Express to Fastify without a rewrite
The smart way to move is incremental. Fastify can mount an Express app as a sub-route via @fastify/express, so you migrate one route at a time and keep the legacy stack alive until the last one moves.
import Fastify from 'fastify';
import expressPlugin from '@fastify/express';
import legacyApp from './legacy-express-app';
const app = Fastify();
await app.register(expressPlugin);
app.use('/legacy', legacyApp);
app.get('/v2/things', async () => ({ migrated: true }));
await app.listen({ port: 3000 });Three rules I follow on these migrations:
- Move the hot routes first. The 5× speedup is real on routes that actually feel framework overhead. CRUD endpoints behind a slow database see almost nothing.
- Convert middleware to hooks one at a time. Auth check first; logging next; rate limit after that. Each hook should land in a separate PR so you can roll back individually.
- Keep both routers running for a sprint. Set up a route-level dashboard and watch p99 latency per route on both stacks. Cut over only when the new path is consistently better.
Decision matrix: which one to pick
| Pick Fastify when | Pick Express when |
|---|---|
| You are starting greenfield in 2026. | The team has shipped Express for years and the app is profitable. |
| You write JSON Schema (or are willing to). | Your API is an aggregator of OAuth flows from many providers. |
| You have measured framework overhead and it actually matters. | You depend on Express-only middleware that isn’t ported. |
| You want OpenAPI docs generated from the schema for free. | You hire generalist devs who learned Express in school. |
| You ship TypeScript-first. | You need every Stack Overflow answer to apply directly. |
Production checklist when you commit to Fastify
- Declare a JSON Schema on every route. Skip this and you lose the speed. Validation runs in a compiled function — it is faster than the Express version.
- Use the built-in pino logger. Don’t bolt on Winston; you will regret the throughput loss. pino integrates so cleanly that disabling it is rarely worth it.
- Replace middleware mental model with hooks.
onRequest,preHandler,onSend,onResponse— they exist for a reason. - Set
app.setNotFoundHandlerearly. Default 404 returns plain text; you almost always want JSON. - Pin the body parser limit. Default is 1 MB and quietly rejects anything larger; set it explicitly so the limit shows up in code review.
- Use
fastify-clior your own boot script that callsawait app.ready()before listening. Plugin loading is async and skippingready()hides errors.
When not to use Fastify at all
Three cases where I tell clients to stay on Express, and one where I tell them to stay on Fastify and not migrate “back” either:
- The app is primarily a webhook receiver and an OAuth gateway. Passport’s coverage of auth providers is unmatched, and webhook signature verification has more battle-tested Express middleware than Fastify.
- The team has zero schema-first experience. Fastify without schemas is “Express minus the ecosystem,” which is a worse trade than just staying.
- You inherited a Sequelize + express-session + Redis app that works. The migration cost won’t pay itself back unless you are also rewriting the data layer, in which case the ORM choice matters more than the framework choice.
- You went Fastify and now want to migrate to NestJS. Don’t. NestJS is a different abstraction (decorators, DI containers) and the migration cost is higher than the value, unless you have a team that demands the Java-flavoured structure.
Troubleshooting FAQ
Is Fastify really 5× faster than Express in production?
No. The 5× number is framework overhead only. With a database, JSON parsing of real payloads, validation, logging, and one downstream HTTP call, the gap drops to 1.5× to 2.5× in my own measurements. Still meaningful for high-throughput APIs; irrelevant for a typical CRUD app under 200 req/s.
Can I use Express middleware in Fastify?
Yes, via @fastify/express. You give up some performance because every middleware crosses the Express compatibility layer. Useful for migration, never for new code.
Does Express 5 close the gap with async error handling?
Partially. Express 5 finally catches async errors thrown from route handlers without express-async-errors. Throughput is roughly the same as Express 4 — the async fix is a quality-of-life win, not a perf win.
Should I pick NestJS over both?
Different question. NestJS sits on top of Express or Fastify and adds Angular-style dependency injection plus opinionated structure. Pick NestJS if you have a 10-engineer team that needs the structure to ship consistently. Skip it for solo or small-team projects where the boilerplate slows you down.
Is Fastify production-ready in 2026?
Yes. Fastify powers production traffic at companies including Microsoft Azure, American Express, and a long list of smaller shops. The plugin ecosystem matters more than maturity at this point.
Hono or Elysia instead?
Both are interesting on edge runtimes (Cloudflare Workers, Bun) where Fastify can’t run. On a vanilla Node.js server, Fastify is still the better default — more battle-tested, broader Node-specific plugin coverage. If you are picking the runtime as well, the framework choice changes.
What about the Fastify v5 breaking changes?
Migration from v4 to v5 is a few hours for most apps. Removed callback-style listen, stricter schema validation, and Node 20 minimum. Worth doing — v5 is the version that gets long-term plugin support.
Do framework choices matter more than database choices?
No. Your database design and your ORM choice drive 80% of API performance once the load is real. Pick the framework you will be productive in and spend your tuning budget on the data layer.
Verdict
For a new Node.js backend in 2026, Fastify is the default. The schema-first design, native TypeScript types, and lower framework overhead pay off across the lifetime of a project — even if you never see the headline 5× number in production.
For an existing Express app that ships and earns, the only good migration reason is a measured bottleneck. “Fastify is faster” is not a measurement; it is marketing. Profile your hot routes, prove framework overhead is the actual problem, then migrate that subset.
The wrong question is “which is better?” The right question is “what does my throughput graph look like, and how much of the latency is mine to optimise?”