Event loop lag в Node.js: почему API отвечает медленно, хотя база не виновата
Event loop lag помогает увидеть проблемы, которые не объясняются медленной базой: CPU-bound код, тяжелая сериализация, sync API и длинные microtask-цепочки.
Когда backend начинает отвечать медленно, первая мысль обычно такая: база тормозит, сеть лагает, Redis не успевает, внешний сервис долго отвечает. Иногда это правда. Но в Node.js есть еще один важный источник задержек - event loop lag.
Event loop lag появляется, когда основной поток Node.js занят и не может вовремя перейти к следующей задаче. Запросы уже пришли, timers уже готовы, socket events уже ждут, но JavaScript все еще выполняет синхронный код.
Почему это неприятно
Node.js часто используют для I/O-heavy backend: REST API, GraphQL, WebSocket, очереди, интеграции, работа с базой. В таких задачах модель работает хорошо: пока база или сеть делают свою работу, event loop может обслуживать другие события.
Но если в request handler попадает тяжелая синхронная операция, весь процесс становится менее отзывчивым. Один пользователь может запустить обработку, из-за которой остальные запросы начинают ждать.
Типичные симптомы:
- p95/p99 latency растет без очевидной причины;
- база отвечает быстро, но API все равно медленный;
- WebSocket-события приходят с задержкой;
- timers и scheduled jobs выполняются позже ожидаемого;
- healthcheck иногда отвечает медленно, хотя сервис “живой”;
- CPU процесса высокий, но I/O не выглядит перегруженным.
Частые причины
Самые частые источники event loop lag в backend:
- большой
JSON.stringifyилиJSON.parse; - обработка больших массивов в синхронном цикле;
- генерация Excel/PDF/документов в основном потоке;
- синхронные filesystem операции;
- тяжелая криптография без worker'ов;
- image processing;
- неудачные регулярные выражения;
- слишком длинные Promise/microtask цепочки;
- логирование огромных объектов.
В коде это может выглядеть невинно:
const rows = await this.repository.findMany(filters);
const enriched = rows.map((row) => heavyTransform(row));
return { items: enriched };
Проблема не в await. После того как данные пришли из базы, map выполняется синхронно. Если rows большой, event loop занят до конца обработки.
Как я думаю о таких местах
Я стараюсь разделять I/O и CPU work. I/O можно параллелить через асинхронную модель Node.js. CPU work нужно ограничивать, выносить или дробить.
Практические вопросы:
- сколько элементов может прийти в этот endpoint;
- есть ли жесткий limit на пагинацию;
- можно ли делать transform на уровне SQL;
- можно ли стримить результат;
- нужно ли выносить генерацию в очередь;
- можно ли перенести тяжелую часть в worker thread;
- что будет, если 20 пользователей одновременно запустят этот сценарий.
Если ответ “процесс просто посчитает”, это часто плохой ответ для Node.js backend.
Очередь не всегда решает проблему
Вынести задачу в BullMQ или другую очередь полезно, но важно понимать: если worker работает в том же Node.js процессе и выполняет тяжелый синхронный код, он тоже может блокировать свой event loop.
Очередь помогает:
- убрать долгую работу из пользовательского HTTP-запроса;
- повторять задачи;
- контролировать concurrency;
- хранить статус процесса;
- логировать ошибки.
Но для CPU-heavy задач иногда нужен отдельный worker process, worker threads или отдельный сервис. Очередь - это способ организовать выполнение, а не автоматическое ускорение вычислений.
Как мониторить
В Node.js можно измерять event loop delay через perf_hooks:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
const p95 = histogram.percentile(95) / 1_000_000;
console.log({ eventLoopDelayP95Ms: p95 });
histogram.reset();
}, 10_000);
Это не заменяет полноценный мониторинг, но помогает увидеть проблему: если задержка event loop растет вместе с latency, нужно искать blocking code, а не только медленные SQL-запросы.
Что я обычно делаю
Мой базовый checklist:
- ставлю лимиты на списки и batch-операции;
- избегаю sync filesystem API в request path;
- не логирую большие payload целиком;
- выношу генерацию документов и отчетов в background jobs;
- контролирую concurrency у worker'ов;
- для CPU-heavy задач рассматриваю worker threads или отдельный сервис;
- проверяю регулярные выражения и большие JSON-операции;
- добавляю метрики latency и event loop delay.
Вывод
Event loop lag - это напоминание, что Node.js асинхронный, но не магический. Он отлично переживает много I/O, но плохо относится к тяжелой синхронной работе в основном потоке.
Для backend-разработчика важно не просто знать фазы event loop, а уметь увидеть их последствия в production: растущую задержку, поздние timers, подвисающие WebSocket-события и request handlers, которые мешают друг другу.