Suggest an editImprove this articleRefine the answer for “Express.js vs Fastify vs Koa — which to choose?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Express.js vs Fastify vs Koa**: Express tops out at ~18K req/s with callback middleware but has the largest ecosystem (500K+ packages). Fastify hits 75K req/s by compiling routes and JSON schemas at startup. Koa gives clean async/await middleware with a minimal core. **Key rule:** performance-critical API? Fastify. Ecosystem and familiarity? Express.Shown above the full answer for quick recall.Answer (EN)Image**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):** ```js 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):** ```js 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):** ```js 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:** ```js // 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:** ```js // 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:** ```js // 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:** ```js // 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:** ```js 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:** ```js 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 forgotten ``` Fastify'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: ```js 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.