open atlas
↑ К треку
Node.js с нуля до senior NODE · 05 · 02

Express против Fastify: middleware и пропускная способность

Express — линейная цепочка middleware (req, res, next): минималистичная, с крупнейшей экосистемой. Fastify меняет это на плагин-модель с JSON-Schema-валидацией и fast-json-stringify-сериализацией — реальным выигрышем throughput. Оба прод-готовы.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Команда перевела 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.

ИзмерениеExpressFastify
Композициялинейная цепочка middlewareплагины + хуки жизненного цикла
Сигнатура обработчика(req, res, next)async (req, reply)
Валидациясвоя (zod/joi)встроенный JSON Schema
СериализацияJSON.stringifyfast-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. 1 Запрос прибывает; роутер сопоставляет его с роутом и его схемой
  2. 2 Хуки onRequest / preParsing / preValidation бегут для этой области
  3. 3 Схема запроса валидирует и приводит params, query и body
  4. 4 Бегут хуки preHandler, затем выполняется твой async-обработчик
  5. 5 Сериализатор fast-json-stringify из схемы ответа кодирует ответ
  6. 6 Сериализованный ответ отправляется обратно клиенту
Вспомните перед уходом
  1. 01
    Fastify отчитывается о ~2x throughput Express в собственных бенчмарках. Почему это часто невидимо в реальном приложении и когда оно реально важно?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.