Skip to main content

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):

js
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-контекст):

js
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 (валідація через схему):

js
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)
Перший реліз201020162014
Стиль 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:

js
// Неправильно - втрачаєш валідацію і ~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:

js
// Неправильно - 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:

js
// Неправильно - заголовок встановлюється після відповіді, губиться 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:

js
// Неправильно - за замовчуванням 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-хуком:

js
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:

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) => { // Ручна валідація - легко пропустити під дедлайном 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 по одному:

js
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 залишається, маршрути переїжджають поступово.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?