Express против Fastify: middleware и пропускная способность
Express — линейная цепочка middleware (req, res, next): минималистичная, с крупнейшей экосистемой. Fastify меняет это на плагин-модель с JSON-Schema-валидацией и fast-json-stringify-сериализацией — реальным выигрышем throughput. Оба прод-готовы.
Команда перевела JSON API на Fastify после поста в блоге, обещавшего «2x throughput», и потом смотрела, как p99-латентность остаётся ровно там, где была. Бенчмарк был настоящим — Fastify действительно отдаёт примерно вдвое больше запросов/сек, чем Express, на крошечном роуте «верни JSON-объект», — но их обработчик тратил 40 мс на ожидание Postgres и 0.2 мс во фреймворке. Фреймворк никогда не был узким местом. Хуже того, два роута тихо сломались: async-обработчик Express 4, который раньше бросал 500, теперь утекал reject как unhandledRejection, потому что его никто не обернул. Они гнались за числом, которое не имело значения, и зашипили баг, который имел.
К концу урока ты поймёшь, что каждый фреймворк реально отдаёт взамен, и сможешь выбирать осознанно — не гнаться за цифрой бенчмарка.
Две философии: цепочка vs инкапсулированное дерево плагинов
Прежде чем выбирать фреймворк, ответь на один вопрос: нужна ли тебе максимально широкая экосистема с простейшей моделью, или встроенная schema-валидация и сериализация? Ответ, как правило, и решает выбор. Express намеренно мал: запрос течёт через линейную цепочку middleware, каждый из которых — функция (req, res, next), мутирующая req/res и вызывающая next(), чтобы передать управление дальше, или next(err), чтобы прыгнуть в обработчик ошибок. В него почти не вшито мнений — ты собираешь роутинг, парсинг тела, авторизацию и логирование из обширной экосистемы app.use()-middleware. Этот минимализм — его суперсила: крупнейший каталог middleware в мире Node, десятилетие боевой обкатки и сигнатура, которую уже знает каждый Node-разработчик.
import express from "express";
const app = express();
// глобальный middleware: бежит на каждый запрос, в порядке регистрации
app.use(express.json());
app.use((req, res, next) => { req.startedAt = Date.now(); next(); });
app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});Fastify ставит на другое: запрос проходит через хуки жизненного цикла (onRequest, preParsing, preValidation, preHandler, …), а роуты группируются в плагины, зарегистрированные через fastify.register(). Определяющая черта — инкапсуляция: плагин получает собственную область, поэтому декораторы, хуки и middleware, зарегистрированные внутри него, не протекают к соседям. Это фича для модульности, но она удивляет беженцев из Express, ожидающих, что app.use() глобален всюду.
import Fastify from "fastify";
const app = Fastify({ logger: true }); // pino встроен
await app.register(async (instance) => {
// этот хук бежит только для роутов внутри области этого плагина
instance.addHook("preHandler", async (req) => { req.startedAt = Date.now(); });
instance.get("/users/:id", async (req) => ({ id: req.params.id }));
});Настоящий разделитель: schema-driven валидация и сериализация
У Express нет встроенной истории валидации или сериализации — ты тянешь zod, joi или express-validator, а сериализуешь через res.json(), который вызывает JSON.stringify. Fastify встраивает и то и другое в определение роута через JSON Schema. Схема запроса валидирует и приводит входящие params/query/body до того, как выполнится обработчик. Схема ответа — это рычаг throughput: fast-json-stringify компилирует из этой схемы специализированную функцию-сериализатор заранее, так что она никогда не обходит объект неизвестной формы в рантайме, как вынужден делать обобщённый JSON.stringify.
app.get("/users/:id", {
schema: {
params: { type: "object", properties: { id: { type: "string" } } },
response: {
200: {
type: "object",
properties: { id: { type: "string" }, name: { type: "string" } },
},
},
},
}, async (req) => getUser(req.params.id));Этот скомпилированный сериализатор — самый релевантный для реального мира выигрыш, потому что он ускоряет каждый ответ, а не только синтетический пустой роут, — и в качестве бонуса поля, которых нет в схеме ответа, вырезаются, так что ты не можешь случайно утечь passwordHash.
| Измерение | Express | Fastify |
|---|---|---|
| Композиция | линейная цепочка middleware | плагины + хуки жизненного цикла |
| Сигнатура обработчика | (req, res, next) | async (req, reply) |
| Валидация | своя (zod/joi) | встроенный JSON Schema |
| Сериализация | JSON.stringify | fast-json-stringify (скомпилирована) |
| Экосистема | крупнейшая, старейшая | большая, плагинная |
| Throughput (свой бенч) | базовый | ~2x на простом JSON |
| Ошибки async-обработчика | Express 4: не ловятся авто | await & роутятся в хук ошибок |
▸Почему это работает
Почему Fastify «~2x» в бенчмарках, но ровный в проде? Его собственный набор бенчмарков молотит роут, возвращающий маленький константный объект, так что единственная работа — парсинг HTTP и сериализация JSON, ровно там, где блестят скомпилированный сериализатор и поджарый роутер. Реальный обработчик добавляет round-trip к БД (часто 5–40 мс) и внешние вызовы, которые затмевают субмиллисекундный оверхед фреймворка. Синтетические числа меряют фреймворк в изоляции; твоя латентность доминируется IO. Преимущество throughput настоящее, но важнее всего оно для high-RPS, тяжёлых по ответам JSON-шлюзов, а не для CRUD-приложений, привязанных к базе.
Ловушка async-ошибок Express 4
Самый острый режим сбоя историчен и до сих пор всюду. Express 4 не ловит зареджекченные промисы из async-обработчиков роутов. Если async-обработчик бросает (или await-ит что-то, что реджектится), у Express 4 нет await вокруг твоей функции, так что reject становится unhandledRejection — который с Node 15 по умолчанию роняет процесс — вместо того, чтобы дойти до твоего middleware обработки ошибок.
// Express 4 — СЛОМАНО: reject ускользает в unhandledRejection
app.get("/u/:id", async (req, res) => {
const user = await db.find(req.params.id); // если это реджектнется…
res.json(user); // …Express 4 никогда не поймает
});
// Express 4 — ФИКС: try/catch + next или обёртка вроде express-async-errors
app.get("/u/:id", async (req, res, next) => {
try { res.json(await db.find(req.params.id)); }
catch (err) { next(err); } // передать в middleware ошибок
});Express 5 (уже релизнут) это чинит: зареджекченные промисы из async-обработчиков автоматически форвардятся в обработчик ошибок, закрывая дыру. У Fastify этой проблемы не было никогда — обработчики async по дизайну, их reject await-ятся и роутятся в хук setErrorHandler. Если ты на Express 4, оборачивай каждый async-обработчик (или прими express-async-errors); тихое падение — самый частый production-баг в старых Express-приложениях.
Ты строишь новый high-RPS публичный JSON API: много мелких read-эндпойнтов, строгая валидация входа, команда готова взяться за TypeScript и есть требование никогда не утекать внутренние поля в ответах. Какой фреймворк подойдёт лучше?
Что на самом деле делает fast-json-stringify и почему это самая релевантная для реального мира фича производительности Fastify?
async-обработчик роута в приложении на Express 4 await-ит вызов БД, который реджектится, и там нет try/catch. Что произойдёт?
Расставь путь запроса в Fastify через жизненный цикл, от прибытия до ответа:
- 1 Запрос прибывает; роутер сопоставляет его с роутом и его схемой
- 2 Хуки onRequest / preParsing / preValidation бегут для этой области
- 3 Схема запроса валидирует и приводит params, query и body
- 4 Бегут хуки preHandler, затем выполняется твой async-обработчик
- 5 Сериализатор fast-json-stringify из схемы ответа кодирует ответ
- 6 Сериализованный ответ отправляется обратно клиенту
- 01Fastify отчитывается о ~2x throughput Express в собственных бенчмарках. Почему это часто невидимо в реальном приложении и когда оно реально важно?
- 02Почему async-обработчик роута может тихо уронить Express 4-сервис и какие есть два способа это починить?
Express и Fastify решают одну задачу с противоположными темпераментами. Express — минимальная, неопинионированная линейная цепочка middleware из функций (req, res, next) с крупнейшей экосистемой в Node и десятилетием боевой обкатки — валидацию, сериализацию и всё прочее ты собираешь из стороннего middleware. Fastify — модель плагинов-и-хуков, чьи плагины инкапсулированы (так что app.use()-стиль глобалов не протекает между областями, что удивляет беженцев из Express), а определяющая черта — JSON Schema: схемы запроса валидируют и приводят вход, а схема ответа компилирует сериализатор fast-json-stringify, который ускоряет каждый JSON-ответ и вырезает поля не из схемы. По производительности собственные бенчмарки Fastify показывают примерно 2x запросов/сек Express, но это меряет фреймворк в изоляции на тривиальном роуте; в реальных приложениях round-trip к базе на 5–40 мс доминирует субмиллисекундный оверхед фреймворка, так что настоящий, переносимый выигрыш — это скомпилированный сериализатор, а не заголовочное число. Классический режим сбоя — Express 4 не ловит reject из async-обработчиков — они ускользают как unhandledRejection и роняют процесс с Node 15, чинится через try/catch + next(err), обёртку вроде express-async-errors или обновление до Express 5 (который форвардит их автоматически, как Fastify всегда). Выбирай Express ради широты экосистемы, знакомства команды и постепенного внедрения; выбирай Fastify ради чувствительных к производительности JSON API, schema-driven валидации/сериализации и структурированной плагин-модели — оба прод-готовы. Теперь, когда встретишь бенчмарк «2x throughput», ты первым делом спросишь: а что делает обработчик — возвращает константу или ходит в базу?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.