Express.js проти Fastify проти Koa — що обрати?
Express.js, Fastify та Koa - три Node.js фреймворки для HTTP-роутингу, які використовують принципово різні підходи до middleware, продуктивності та плагінів.
Теорія
TL;DR
- Express: Swiss Army knife з 500K+ npm-пакетами, callback-ланцюги, працює всюди, але впирається в ~18K req/s
- Fastify компілює JSON-схеми у скінченні автомати, маршрути у radix-дерево (radix trie), і видає 75K req/s в autocannon-бенчмарках
- Koa (написана тією ж командою, що й Express) замінила callbacks на async/await, але має значно меншу екосистему
- Правило вибору: потрібно >10K req/s або чітка валідація? Fastify. Команда вже на Express? Лишайся. Переписуєш старий Express на чистий async? Koa.
Швидкий приклад
Один і той самий POST-маршрут, що повертає тіло запиту, на трьох фреймворках:
Express (ланцюг callbacks):
const express = require('express');
const app = express();
app.use(express.json());
app.post('/echo', (req, res) => {
res.json(req.body); // Без валідації - пропускає будь-який JSON
});
app.listen(3000);Koa (async-контекст):
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx) => {
if (ctx.path === '/echo') {
ctx.body = ctx.request.body; // Потрібен koa-body
}
});
app.listen(3000);Fastify (валідація через схему):
const fastify = require('fastify')({ logger: true });
fastify.post('/echo', {
schema: {
body: {
type: 'object',
properties: { msg: { type: 'string' } }
}
}
}, (req, reply) => {
reply.send(req.body); // Валідовано до запуску хендлера
});
fastify.listen({ port: 3000 });Fastify відхиляє некоректні дані ще до того, як хендлер запускається. Express мовчки їх пропускає.
Головна різниця
Express будує шаровий роутер з RegExp-матчингом по всіх зареєстрованих шляхах на кожен запит - O(n*m). При 50+ маршрутах це стає відчутним. Fastify при старті компілює маршрути у radix-дерево (O(k), де k - довжина шляху) і JSON-схеми у функції серіалізації через fast-json-stringify, обходячи динамічний доступ до властивостей у V8. До моменту коли запит доходить до хендлера, і валідація, і серіалізація вже є скомпільованим кодом. Koa навмисно нічого з цього не робить. Ти отримуєш об'єкт контексту (ctx) і чистий middleware-стек через await next(), але без автоматичної оптимізації. Autocannon-бенчмарки на маршруті "hello world" показують: Fastify 75K req/s, Koa 25K, Express 18K.
Коли що обирати
- Навантажений API (10K+ req/s): Fastify. Схема блокує погані дані і прискорює серіалізацію одночасно.
- Швидкий прототип або команда вже на Express: Express. Екосистема npm не має рівних - 500K+ пакетів, passport, morgan, все є.
- TypeScript-first мікросервіс: Fastify. Нативні generics (
FastifyInstance), не потрібен@types/. - Переписуєш Express-callbacks на чистий async: Koa. Модель delegation (передача контролю через
await next()) робить middleware-композицію читабельною. - Легасі-кодова база: Express. Нульова переробка існуючих маршрутів і middleware.
Таблиця порівняння
| Параметр | Express.js (v4.19) | Fastify (v5.0) | Koa (v2.15) |
|---|---|---|---|
| Перший реліз | 2010 | 2016 | 2014 |
| Стиль middleware | Ланцюг callbacks (app.use(fn)) | Async-хуки + плагіни (fastify.register()) | Async/await (await next()) |
| Продуктивність (autocannon) | ~18K req/s | ~75K req/s | ~25K req/s |
| Парсинг JSON | Ручний (express.json()) | На основі схем (fast-json-stringify) | Ручний (koa-body) |
| TypeScript | @types/express | Нативні generics | @types/koa |
| Розмір | ~50KB | ~200KB з плагінами | ~20KB |
| Екосистема | 500K+ npm-пакетів | 1K+ офіційних плагінів | ~10K пакетів |
| Коли обирати | Моноліти, MVP | Мікросервіси, навантажені API | Чисті async-переробки |
Як компілюються маршрути і схеми
Node.js http.Server генерує подію request для кожного з'єднання. Express перехоплює її і послідовно проходить через всі зареєстровані шари, виконуючи RegExp-матчинг. Fastify вставляє radix-дерево між сервером і хендлерами. Маршрут /api/users/:id компілюється у вузол дерева при старті, тому матчинг під час запиту - це один прохід O(k). JSON-схема через Ajv компілюється у функцію валідації теж при старті, а не при кожному запиті. fast-json-stringify робить те саме для серіалізації. Запит доходить до хендлера, а вся підготовча робота вже виконана.
Koa цього не робить. Мінімалізм - це її свідомий вибір.
Типові помилки
Ігнорування схем у Fastify:
// Неправильно - втрачаєш валідацію і ~50% приросту швидкості
fastify.post('/users', (req, reply) => {
reply.send(req.body);
});
// Правильно - компілюється у скінченний автомат, валідує до хендлера
fastify.post('/users', {
schema: {
body: { type: 'object', properties: { name: { type: 'string' } } }
}
}, (req, reply) => {
reply.send(req.body);
});Вбудована auth-логіка прямо в маршрут Express:
// Неправильно - callbacks вкладаються, помилки важко зловити централізовано
app.get('/users', (req, res, next) => {
if (!req.user) return next();
res.json(users);
});
// Правильно - один middleware, всі маршрути захищені
app.use(authMiddleware);
app.get('/users', handler);Неправильний порядок middleware в Koa:
// Неправильно - заголовок встановлюється після відповіді, губиться
app.use(async (ctx) => {
ctx.body = 'response'; // хендлер перший
});
app.use(async (ctx, next) => {
ctx.set('X-Foo', 'bar'); // запізно - body вже задане
await next();
});
// Правильно - заголовки і логування - назовні
app.use(async (ctx, next) => {
ctx.set('X-Foo', 'bar');
await next(); // потім хендлер
});
app.use(async (ctx) => {
ctx.body = 'response';
});Відсутність ctx.throw() для помилок у Koa:
// Неправильно - за замовчуванням 404, виглядає як відсутній маршрут у логах
app.use(async (ctx) => {
if (!valid) ctx.body = 'error'; // статус залишається 404
});
// Правильно - встановлює коректний HTTP-статус
app.use(async (ctx) => {
if (!valid) ctx.throw(400, 'Invalid input');
});Де використовується
- Express: API-шлюзи Netflix (middleware для rate-limiting), інтеграції з Mongoose ODM, будь-що де "просто встанови пакет"
- Fastify: PaaS-рантайм Platform.sh, GraphQL через Mercurius (Fastify-нативний GraphQL-сервер для навантажень рівня Spotify), TypeScript-мікросервіси з чіткими схемними контрактами
- Koa: кастомні сервери невеликих команд, що переписують старий Express-код; async-важкі застосунки на кшталт бекендів WeChat mini-programs
Бачив команди, що витрачали тиждень на міграцію навантаженого внутрішнього API з Express на Fastify і знижували p99-затримку на 40%. Більшість приросту давала схемно-компільована серіалізація замість ручного JSON.parse у кожному хендлері.
Питання на співбесіді
Q: Чому Fastify швидший за Express на рівні роутингу?
A: Матчинг маршрутів через radix-дерево (O(k) на запит) проти RegExp-матчингу Express по всіх зареєстрованих маршрутах (O(n*m)). Серіалізація JSON компілюється у функцію при старті через fast-json-stringify, замість динамічного JSON.stringify на кожен запит.
Q: Що зламається при міграції з Express на Fastify?
A: res.json() стає reply.send(). app.param() у Fastify не існує - замість нього хуки. Middleware через app.use() потребує fastify.register() або прошарку сумісності @fastify/express. Не всі Express-middleware працюють напряму.
Q: Як відрізняється обробка помилок у трьох фреймворках?
A: Express: next(err) передає до error-handling middleware з 4 параметрами. Koa: ctx.throw() або try/catch у найзовнішньому middleware. Fastify: setErrorHandler() реєструє типізований обробник, помилки логуються через Pino у структурованому форматі за замовчуванням.
Q: Як правильно бенчмаркувати перед вибором?
A: Запусти autocannon -c 100 -d 20 -p 10 localhost:3000 на представницькому маршруті з реальними DB-викликами, а не просто "hello world". На схемно-валідованих маршрутах Fastify дає 70K+ rps, Express близько 15K. Розрив звужується коли справжній bottleneck - I/O, а не CPU.
Q: Які підводні камені з TypeScript у кожному фреймворку?
A: Fastify має нативні generics: FastifyRequest<{ Body: CreateUserBody }> дає повну типову безпеку без додаткових налаштувань. Express потребує RequestHandler<Params, Body, Query> з @types/express. У Koa типізація контексту найслабша - ctx.request.body за замовчуванням має тип unknown.
Приклади
Той самий ендпоінт створення користувача на трьох фреймворках
Fastify з preHandler-хуком:
const fastify = require('fastify')();
fastify.register(async (f) => {
// Симульована авторизація - заміни на реальний 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 (схема відхиляє до хендлера)Еквівалент на Express:
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) => {
// Ручна валідація - легко пропустити під дедлайном
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);
// Той самий результат, але валідацію легко забути написатиСхема Fastify запускається до хендлера. В Express про валідацію треба пам'ятати самому.
Шлях міграції: Express-middleware у Fastify
Міграція з Express на Fastify не обов'язково відбувається за один раз. Прошарок @fastify/express дозволяє існуючому middleware працювати далі, поки маршрути переходять на нативний Fastify по одному:
const fastify = require('fastify')();
// Прошарок сумісності для існуючих Express-middleware
await fastify.register(require('@fastify/express'));
fastify.use(require('cors')());
fastify.use(require('helmet')());
// Нативний Fastify-маршрут поруч із legacy Express-middleware
fastify.get('/api/health', async () => ({ status: 'ok' }));
fastify.listen({ port: 3000 });Саме так виглядають більшість реальних міграцій: middleware залишається, маршрути переїжджають поступово.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.