Express vs Fastify in 2026: A Production Benchmark and Migration Guide
Express has 70 million weekly downloads but its synchronous middleware model costs you throughput on every request. Fastify claims a 2x speedup with schema validation baked in. Here is the real benchmark under load, the migration strategy that avoids a full rewrite, and the one scenario where Express stays the better choice.
The team is building a new microservice. The decision of which HTTP framework to use takes forty-five minutes in a meeting and nobody is happy with the outcome. Half the team wants Express because “it is what we know” and every existing service uses it. The other half wants Fastify because “it is 2x faster” and has schema validation out of the box.
Both sides are right. Express is what you know, and Fastify is measurably faster. But the question is not which framework has better benchmarks on a synthetic hello-world endpoint. The question is: does the throughput difference matter for your workload, and is the migration cost worth it?
This post gives you an honest benchmark under realistic conditions (not just JSON.stringify on an empty object), a migration strategy that lets you run both frameworks side by side, and the three questions you should ask before picking either one.
What Express actually costs you
Express was designed in 2010. Its middleware model is synchronous: each middleware function calls next() and the framework walks the chain serially. When V8 was single-core and servers handled hundreds of requests per second, this was fine. On a modern 16-core machine handling 10,000 requests per second, the cost of that synchronous dispatch shows up in the flame graph.
Here is what happens on every Express request:
// Express internally walks its middleware stack like this:
function next(err) {
// increment index
var layer = stack[i++];
if (!layer) {
// no more middleware, send response
return;
}
// try/catch per middleware to convert sync throws to async errors
try {
layer.handle_request(req, res, next);
} catch (e) {
layer.handle_error(e, req, res, next);
}
}
Every next() call is a function dispatch. Every middleware layer has a try/catch wrapper even if your handler never throws. When you have 8 middleware functions (body parser, cookie parser, compression, authentication, authorization, rate limiting, request logging, and your route handler), that is 8 function calls, 8 try/catch evaluations, and 8 property lookups before a single line of business logic runs.
The cost is small per request. At 10,000 requests per second, it adds up to real CPU time that could be serving user requests instead of walking a middleware chain.
How Fastify is different
Fastify was designed in 2016 with performance as a first-class concern. Its key differences:
Encapsulated context. Instead of a flat middleware stack, Fastify uses a tree of plugins. Each plugin gets its own scope, so the framework does not need to iterate through unrelated middleware on every request. The routing is O(log n) instead of O(n).
JSON serialization off the main thread. Fastify uses fast-json-stringify to precompile serialization schemas into optimized functions. Express uses JSON.stringify(), which is a C++ binding but still runs on the main thread and pays V8’s full parse-stringify tax.
Schema-based serialization. When you provide a response schema, Fastify generates a serialization function at startup that is faster than JSON.stringify() because it avoids property name lookups:
// Fastify: schema-compiled serialization
const schema = {
response: {
200: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
email: { type: 'string', format: 'email' }
}
}
}
};
app.get('/user/:id', { schema }, async (req, reply) => {
const user = await db.findUser(req.params.id);
// reply serializes using the precompiled schema
return user;
});
Under the hood, Fastify compiles that schema into a function that directly builds the JSON string by concatenating known property values, skipping the generic JSON.stringify path entirely.
No try/catch on every request. Fastify uses a single top-level error handler. If your handler throws, the error propagates to the framework boundary, not through every middleware layer. This is a measurable win in throughput when errors are rare (which they should be in a well-written service).
The benchmark that matters
Hello-world benchmarks are misleading. They measure the framework’s routing overhead with an empty handler. Real services parse bodies, validate inputs, serialize responses, and handle headers.
Here is a benchmark that simulates a realistic CRUD endpoint: parse a JSON body, validate the payload against a schema, query a database (simulated with a 2ms delay), and return a JSON response with computed headers.
The setup uses autocannon on a 4-core machine with Node.js 22:
// Express version
import express from 'express';
import { body, validationResult } from 'express-validator';
const app = express();
app.use(express.json());
app.post('/users', [
body('email').isEmail(),
body('name').isString().isLength({ min: 1, max: 100 }),
body('age').isInt({ min: 0, max: 150 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Simulate DB query: 2ms
await new Promise(r => setTimeout(r, 2));
res.status(201).json({
id: Date.now(),
email: req.body.email,
name: req.body.name,
age: req.body.age,
createdAt: new Date().toISOString()
});
});
// Fastify version
import Fastify from 'fastify';
const app = Fastify();
const createUserSchema = {
body: {
type: 'object',
required: ['email', 'name', 'age'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1, maxLength: 100 },
age: { type: 'integer', minimum: 0, maximum: 150 }
}
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'integer' },
email: { type: 'string' },
name: { type: 'string' },
age: { type: 'integer' },
createdAt: { type: 'string' }
}
}
}
};
app.post('/users', { schema: createUserSchema }, async (req, reply) => {
// Simulate DB query: 2ms
await new Promise(r => setTimeout(r, 2));
reply.status(201).send({
id: Date.now(),
email: req.body.email,
name: req.body.name,
age: req.body.age,
createdAt: new Date().toISOString()
});
});
The results on Node.js 22, running 20 concurrent connections for 30 seconds:
| Metric | Express | Fastify | Difference |
|---|---|---|---|
| Requests/sec | 4,220 | 4,510 | +6.9% |
| Latency p50 | 4.71 ms | 4.42 ms | -6.2% |
| Latency p99 | 8.12 ms | 7.83 ms | -3.6% |
| CPU usage | 78% | 74% | -5.1% |
When the simulated database delay is removed (pure framework overhead):
| Metric | Express | Fastify | Difference |
|---|---|---|---|
| Requests/sec | 18,400 | 28,700 | +56% |
| Latency p50 | 1.08 ms | 0.69 ms | -36% |
The headline number from Fastify marketing (2x faster) only shows up when your handlers do almost nothing. In a realistic CRUD endpoint where a 2ms database query dominates the response time, the difference is 7%. That 7% may or may not matter to you.
What matters more than raw throughput is the consistency story: Fastify’s p99 stays closer to its p50 under high concurrency because the serialization path does not fluctuate with GC pressure.
Where Express still wins
Express has advantages that benchmarks do not capture:
Ecosystem depth. Express has 70 million weekly downloads. Every middleware, every ORM, every monitoring library has been tested with Express. Fastify has a smaller ecosystem. Some libraries publish Fastify-specific plugins, but many do not. If you depend on a niche Express middleware, you may need to write a wrapper.
Zero migration cost. Every Node.js developer knows Express. New hires can be productive on day one. The Express middleware pattern is the lingua franca of Node.js HTTP servers. Fastify’s plugin model, lifecycle hooks, and decorator pattern have a steeper learning curve.
File uploads. Express with multer handles multipart form data reliably. Fastify has @fastify/multipart, but it handles streaming differently and some edge cases (large files with field limits, mixed multipart) behave differently. If file uploads are a core part of your service, Express is the safer choice.
Proxied connection handling. Express trusts X-Forwarded-* headers with the trust proxy setting. Fastify delegates this to @fastify/proxy-addr. The default behavior differs: Express trusts the first proxy by default, Fastify trusts none. If you are behind a load balancer and do not configure this explicitly, Fastify will see all client IPs as the load balancer’s internal address.
The migration pattern: run both
If you have an existing Express service and want to migrate to Fastify, do not do a big bang rewrite. The safest migration runs both frameworks on the same process using Fastify’s Express compatibility layer or a reverse proxy.
Fastify ships @fastify/express which wraps Express middleware so you can reuse your existing middleware stack:
import Fastify from 'fastify';
import expressPlugin from '@fastify/express';
const app = Fastify();
// Register the Express compatibility plugin
await app.register(expressPlugin);
// Now you can use Express middleware directly
app.use(require('cors')());
app.use(require('morgan')('combined'));
app.use(require('express-rate-limit')({ windowMs: 60000, max: 100 }));
// Routes still use Fastify's schema-validated handlers
app.post('/users', { schema: createUserSchema }, async (req, reply) => {
// Fastify handler
});
This lets you migrate middleware one piece at a time. Replace the body parser first (Fastify’s native body parser is faster and validates schemas). Replace the logger next. Keep your authentication middleware in Express compatibility mode until you have time to rewrite it as a Fastify plugin.
For a per-route migration, put a reverse proxy in front that routes traffic to either an Express instance or a Fastify instance based on a header:
// nginx-split.conf snippet
# Route 80% of traffic to Express, 20% to Fastify
split_clients "${remote_addr}${http_user_agent}" $backend {
80% express_upstream;
20% fastify_upstream;
}
# Or route by URL prefix
location /api/v2/ {
proxy_pass http://fastify_backend;
}
location /api/v1/ {
proxy_pass http://express_backend;
}
The canary approach: deploy Fastify alongside Express, send 5% of traffic to it, compare p99 latencies and error rates for a week, then ramp up.
When to stay on Express
Do not migrate to Fastify if any of these are true:
Your handlers spend more than 10ms on I/O. If every request hits a database, an external API, or a file system, the framework overhead is noise. Spend your optimization budget on query performance, not framework swapping.
You depend on Express-specific middleware with no Fastify equivalent. Some libraries like express-session (with in-memory stores), passport.js strategies, and custom error-handling middleware that inspects res.statusCode assume Express internals. The compatibility layer helps, but you will hit edge cases.
Your team has no performance budget for this service. If the service handles 50 requests per second and nobody is complaining, the migration is a distraction. The time spent migrating is time not spent on features, bugs, or observability.
You use Next.js or Remix on the same server. These frameworks embed Express or build their own HTTP abstractions. Mixing Fastify on the same host adds operational complexity with no benefit.
When to pick Fastify
Fastify is the better choice for these scenarios:
You are building a new service from scratch. Zero migration cost, full schema validation, better performance. The startup cost of learning the plugin model is small when you have no existing code to migrate.
Your service is CPU-bound on serialization. If you serve hundreds of thousands of requests per second and JSON.stringify shows up in your flame graph, Fastify’s compiled serialization is a measurable win. This matters for gateway services, API routers, and aggregation layers that transform payloads without hitting a database.
You want OpenAPI documentation without a separate tool. Fastify’s schema system integrates with @fastify/swagger to generate OpenAPI specs from your route schemas automatically:
import swagger from '@fastify/swagger';
import ui from '@fastify/swagger-ui';
await app.register(swagger, {
openapi: {
info: { title: 'User API', version: '1.0.0' }
}
});
await app.register(ui, { routePrefix: '/docs' });
// Your schema-validated routes automatically appear in the docs
app.post('/users', { schema: createUserSchema }, handler);
No decorators, no JSDoc annotations, no separate YAML file. Your route schema is your documentation.
The decision framework
Here is the three-question process I use:
- Is this a new service or an existing one? New = Fastify. Existing = measure the gap first.
- What is the p50 response time of your current endpoints? Under 5ms per handler = framework choice matters. Over 10ms = framework choice is noise.
- How many concurrent requests does this service handle? Under 500/sec = either framework works. Over 2,000/sec = run the benchmark above with your actual request size and schema.
Most services fall into “Express is fine, Fastify is slightly better.” The migration only pays off if you are building new services or if the CPU savings from serialization justify the investment.
If you decide to migrate, do it via @fastify/express and a canary deployment. Never rewrite a production Express service to Fastify in a single sprint. The risk of subtle behavioral differences (error handling, header casing, stream backpressure) is not worth the throughput gain for most workloads.
The one thing both frameworks get wrong
Neither Express nor Fastify handles backpressure correctly by default. If your downstream is slow and your response body is large, both frameworks buffer the entire response in memory before sending it to the socket. Fastify’s reply.send() returns a Promise that resolves after the data is queued, not after it is flushed.
If you stream large responses, wrap your send in a backpressure check:
// Both frameworks: same problem, same fix
app.get('/large-file', async (req, reply) => {
const stream = createReadStream('/path/to/large/file');
// Fastify
reply.raw.on('drain', () => stream.resume());
stream.on('data', (chunk) => {
if (!reply.raw.write(chunk)) {
stream.pause();
}
});
// Express is identical: res is a Node.js Writable
});
This is a reminder that no framework replaces understanding how the underlying transport works.
A note from Yojji
The kind of engineering decision this post describes (benchmarking framework overhead against real workloads, measuring the actual impact before migrating, understanding the serialization path from the event loop up) is the difference between a service that performs predictably under load and one that surprises you at 2 AM during a traffic spike. It is also exactly the kind of infrastructure-level thinking Yojji’s teams bring to every project they build.
Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and full-cycle product engineering, including the API architecture and performance optimization that keeps production systems stable as traffic grows.