Express.js vs Fastify vs Koa — which to choose?
Express.js, Fastify, and Koa - three Node.js frameworks for HTTP routing that take fundamentally different approaches to middleware design, performance optimization, and plugin systems.
Theory
TL;DR
- Express is the Swiss Army knife: 500K+ npm packages, callback chains, works everywhere, tops out around 18K req/s
- Fastify compiles JSON schemas to finite-state machines and routes to a radix trie, hitting 75K req/s in autocannon benchmarks
- Koa (written by the original Express team) replaces callbacks with async/await but has a much smaller ecosystem than either
- Decision rule: need >10K req/s or strict validation? Fastify. Team already on Express? Stay. Rewriting Express with cleaner async? Koa.
Quick example
The same JSON POST route across all three frameworks, each echoing the request body:
Express (callback chain):
const express = require('express');
const app = express();
app.use(express.json());
app.post('/echo', (req, res) => {
res.json(req.body); // No validation - any JSON passes through
});
app.listen(3000);Koa (async context):
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx) => {
if (ctx.path === '/echo') {
ctx.body = ctx.request.body; // Requires koa-body middleware
}
});
app.listen(3000);Fastify (schema-validated):
const fastify = require('fastify')({ logger: true });
fastify.post('/echo', {
schema: {
body: {
type: 'object',
properties: { msg: { type: 'string' } }
}
}
}, (req, reply) => {
reply.send(req.body); // Schema validated before handler runs
});
fastify.listen({ port: 3000 });Fastify rejects malformed input before your handler even runs. Express silently passes it through.
Key difference
Express builds a layered router that runs RegExp matching across all registered paths on every request (O(n*m)). Fastify pre-compiles routes into a radix trie at startup (O(k), where k is path length) and JSON schemas into serializers via fast-json-stringify, bypassing V8's dynamic property access entirely. By request time, both validation and serialization are already compiled code. Koa does none of this by design. You get a clean context object (ctx) and async middleware composition through await next(), but no automatic performance optimization. The tradeoff is real: autocannon benchmarks on a "hello world" route show Fastify at 75K req/s, Koa at 25K, and Express at 18K.
When to use
- High-traffic API (10K+ req/s): Fastify. Schema validation blocks bad data and speeds up serialization simultaneously.
- Quick prototype or team already on Express: Express. The npm ecosystem is unmatched - 500K+ packages, passport, morgan, everything already exists.
- TypeScript-first microservice: Fastify. Native generics (
FastifyInstance), no@types/workaround needed. - Rewriting messy Express callbacks into clean async: Koa. The delegation model makes middleware composition readable.
- Legacy codebase: Express. Zero rewrite for existing routes and middleware.
Comparison table
| Feature | Express.js (v4.19) | Fastify (v5.0) | Koa (v2.15) |
|---|---|---|---|
| First release | 2010 | 2016 | 2014 |
| Middleware style | Callback chain (app.use(fn)) | Async hooks + plugins (fastify.register()) | Async/await (await next()) |
| Performance (autocannon) | ~18K req/s | ~75K req/s | ~25K req/s |
| JSON parsing | Manual (express.json()) | Schema-based (fast-json-stringify) | Manual (koa-body) |
| TypeScript | @types/express | Native generics | @types/koa |
| Bundle size | ~50KB | ~200KB with plugins | ~20KB |
| Ecosystem | 500K+ npm packages | 1K+ official plugins | ~10K packages |
| When to use | Monoliths, MVPs | Microservices, high-load APIs | Clean async rewrites |
How routes and schemas compile
Node.js http.Server emits a request event for every incoming connection. Express catches it and walks through all registered layers in order, running RegExp matching on each. At 50+ routes, that adds up. Fastify inserts a radix trie between the server and your handlers. Route /api/users/:id compiles to a trie node at startup, so matching at runtime is a single O(k) traversal. The JSON schema goes further: Fastify uses Ajv to compile your schema into a validation function at startup, not per request. fast-json-stringify does the same for serialization. By the time a request hits your handler, both the validation and the serialization plan are prebuilt.
Koa intentionally does none of this. Minimalism is the point.
Common mistakes
Ignoring Fastify schemas and treating it like Express:
// Wrong - loses validation and roughly 50% of the speed benefit
fastify.post('/users', (req, reply) => {
reply.send(req.body);
});
// Right - compiles to a finite-state machine, validates before handler
fastify.post('/users', {
schema: {
body: { type: 'object', properties: { name: { type: 'string' } } }
}
}, (req, reply) => {
reply.send(req.body);
});Nesting auth logic inline in Express instead of centralizing it:
// Wrong - callback nesting, no central error handling
app.get('/users', (req, res, next) => {
if (!req.user) return next();
// auth logic mixed into route
res.json(users);
});
// Right - one middleware, all routes benefit
app.use(authMiddleware);
app.get('/users', handler);Wrong middleware order in Koa:
// Wrong - header set after body is written, gets lost
app.use(async (ctx) => {
ctx.body = 'response'; // handler runs first
});
app.use(async (ctx, next) => {
ctx.set('X-Foo', 'bar'); // too late
await next();
});
// Right - outer middleware runs first on the way in, last on the way out
app.use(async (ctx, next) => {
ctx.set('X-Foo', 'bar');
await next(); // then the handler
});
app.use(async (ctx) => {
ctx.body = 'response';
});Not using ctx.throw() for errors in Koa:
// Wrong - defaults to 404, looks like a missing route in logs
app.use(async (ctx) => {
if (!valid) ctx.body = 'error'; // status is still 404
});
// Right - sets correct HTTP status and a structured error
app.use(async (ctx) => {
if (!valid) ctx.throw(400, 'Invalid input');
});Real-world usage
- Express: Netflix API gateways (rate-limiting middleware stacks), Mongoose ODM integrations, anything with "just install a package" requirements
- Fastify: Platform.sh PaaS runtime, GraphQL with Mercurius (Fastify-native GraphQL server at Spotify-level scale), TypeScript microservices where schema contracts matter
- Koa: Custom servers from small teams rewriting old Express code, async-heavy apps like WeChat mini-program backends
I've seen teams spend a week migrating a high-traffic internal API from Express to Fastify and cut p99 latency by 40%. Most of the gain came from eliminating ad-hoc JSON.parse calls and getting schema-compiled serialization instead.
Follow-up questions
Q: Why is Fastify faster than Express at the routing level?
A: Route matching uses a radix trie (O(k) per request) vs Express's RegExp matching across all registered routes (O(n*m)). JSON serialization compiles to a function at startup via fast-json-stringify, skipping dynamic JSON.stringify on every request.
Q: What breaks when you migrate from Express to Fastify?
A: res.json() becomes reply.send(). app.param() has no equivalent in Fastify, use hooks instead. Middleware registered via app.use() needs fastify.register() or the @fastify/express compatibility layer. Not all Express middleware works directly.
Q: How does error handling differ across all three?
A: Express: next(err) passes to an error-handling middleware with 4 parameters. Koa: ctx.throw() or a wrapping try/catch in the outermost middleware. Fastify: setErrorHandler() registers a typed handler, and Pino logs errors as structured JSON by default.
Q: How would you benchmark your choice before committing to it?
A: Run autocannon -c 100 -d 20 -p 10 localhost:3000 against a representative route that includes DB calls, not just "hello world." Expect Fastify at 70K+ rps on schema-validated routes, Express around 15K. The gap narrows when I/O is the actual bottleneck, which is why for DB-heavy apps the choice matters less than it looks on paper.
Q: What are the TypeScript pitfalls in each framework?
A: Fastify has native generics: FastifyRequest<{ Body: CreateUserBody }> gives full type safety with no extra setup. Express needs RequestHandler<Params, Body, Query> from @types/express. Koa's context typing is weakest - ctx.request.body is unknown by default unless you manually patch the types.
Examples
Same user creation endpoint, three frameworks
Fastify with a preHandler hook:
const fastify = require('fastify')();
fastify.register(async (f) => {
// Simulated auth - replace with real JWT decode
f.addHook('preHandler', async (req) => {
req.user = { id: 1 };
});
f.post('/users', {
schema: {
body: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string' } }
}
}
}, async (req) => {
return { id: 1, name: req.body.name, owner: req.user.id };
});
});
fastify.listen({ port: 3000 });
// POST /users {"name":"Alice"} → 200 {"id":1,"name":"Alice","owner":1}
// POST /users {"name":123} → 400 (schema rejects before handler)Express equivalent:
const express = require('express');
const app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = { id: 1 };
next();
});
app.post('/users', (req, res) => {
// Manual validation - easy to skip under deadline pressure
if (typeof req.body.name !== 'string') {
return res.status(400).json({ error: 'name must be a string' });
}
res.json({ id: 1, name: req.body.name, owner: req.user.id });
});
app.listen(3000);
// Same output, but validation is manual and can be forgottenFastify's schema runs before the hook. Express requires you to remember to validate. At scale, that gap matters.
Migration path: Express middleware inside Fastify
Moving a codebase from Express to Fastify doesn't have to be all-or-nothing. The @fastify/express compatibility layer lets existing middleware keep running while you migrate routes one at a time:
const fastify = require('fastify')();
// Compatibility layer for existing Express middleware
await fastify.register(require('@fastify/express'));
fastify.use(require('cors')());
fastify.use(require('helmet')());
// New native Fastify route alongside legacy Express middleware
fastify.get('/api/health', async () => ({ status: 'ok' }));
fastify.listen({ port: 3000 });This is how most real migrations work in practice: keep the middleware running, move the routes, measure, repeat.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.