Event loop в Node.js под нагрузкой: что ломается первым
Сервис держит 200 RPS, но при 800 latency p99 подскакивает с 40 мс до 3 секунд, хотя CPU не в потолке. Разбираем, как фазы event loop и очередь микрозадач превращаются в узкое место.

Типичная картина: HTTP-сервис на Node.js 20.x спокойно держит 200 запросов в секунду, p99 держится около 40 мс. Нагрузку поднимают до 800 RPS — CPU показывает 60%, память стабильна, а p99 улетает к 3 секундам. Логи чистые, ошибок нет, GC не свирепствует. Виновник — не код обработчика, а то, как event loop распределяет время между фазами и очередью микрозадач. Разберём, что именно упирается первым и как это увидеть до того, как заметят пользователи.
Что такое одна итерация event loop
Event loop — это цикл libuv, который на каждой итерации (tick) проходит фиксированный набор фаз в строгом порядке. Каждая фаза обрабатывает свою очередь колбэков, пока та не опустеет или пока не будет исчерпан внутренний лимит на количество колбэков за проход.
- timers — колбэки
setTimeoutиsetInterval, время которых наступило; - pending callbacks — отложенные системные колбэки (например, некоторые ошибки TCP);
- poll — забирает новые события ввода-вывода: завершённые чтения из сокетов, файлов, ответы БД. Здесь loop может заблокироваться, ожидая событий, если делать больше нечего;
- check — колбэки
setImmediate; - close callbacks — колбэки закрытия, вроде
socket.on('close').
Между переходами через любую фазу движок опустошает две очереди микрозадач: сначала колбэки process.nextTick, затем колбэки промисов (then/catch/finally, await). Это ключевой момент: микрозадачи не ждут своей фазы — они выполняются сразу после текущего колбэка, до перехода к следующему.
Почему микрозадачи опасны под нагрузкой
Очередь микрозадач опустошается полностью, прежде чем loop двинется дальше. Если внутри одного колбэка вы породили промис, который в своём then породил ещё один промис, и так по цепочке — loop не перейдёт к фазе poll и не заберёт новые соединения, пока вся цепочка не завершится. Под нагрузкой в 200 запросов каждый добавляет свою порцию микрозадач, и очередь начинает расти быстрее, чем разгружается.
Node.js не «медленный под нагрузкой». Он честно выполняет всё, что вы в него положили, в одном потоке — и если микрозадачи не дают ему дойти до фазы poll, новые запросы физически не читаются из сокета, даже когда данные уже пришли.
Что ломается первым
При росте RPS деградация идёт не равномерно. Первым проседает не пропускная способность, а задержка приёма новых соединений, потому что фаза poll получает всё меньше времени.
1. Синхронный CPU-код внутри обработчика
Самая частая причина. Одна синхронная операция на 50 мс — это 50 мс, в течение которых loop не делает вообще ничего: не читает сокеты, не запускает таймеры. При 200 RPS такая операция в каждом запросе создаёт очередь, и latency растёт линейно, потом взрывообразно.
Типичные виновники: JSON.parse/JSON.stringify на больших объектах, синхронная криптография (crypto.pbkdf2Sync), регулярки с катастрофическим бэктрекингом, синхронные вызовы fs.readFileSync в горячем пути.
2. Голодание фазы poll из-за микрозадач и nextTick
Рекурсивный process.nextTick — классическая ловушка. Поскольку nextTick-очередь опустошается перед промисами и перед переходом к следующей фазе, бесконечная или очень длинная цепочка nextTick полностью останавливает loop:
// Так делать нельзя: loop никогда не дойдёт до poll
function schedule() {
process.nextTick(schedule); // очередь nextTick не пустеет никогда
}
schedule();
// новые HTTP-соединения не будут приняты
С промисами эффект мягче, но под нагрузкой похож: длинные цепочки await в горячем пути откладывают poll.
3. Таймеры, которые накапливаются
Если на каждый запрос вешать setTimeout, а обработчик таймера сам делает работу, при пиковой нагрузке фаза timers начинает обрабатывать сотни колбэков за проход. Это отнимает время у poll и у обработки уже установленных соединений.
Как это измерить, а не угадывать
Главная метрика — задержка event loop (event loop lag): сколько времени проходит между тем, когда колбэк должен был выполниться, и когда он реально выполнился. В Node.js 12+ есть штатный инструмент без сторонних пакетов.
const { monitorEventLoopDelay } = require('node:perf_hooks');
// resolution — шаг замера в миллисекундах
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
// Раз в 5 секунд выводим перцентили задержки loop в миллисекундах
setInterval(() => {
console.log({
p50: (histogram.percentile(50) / 1e6).toFixed(2),
p99: (histogram.percentile(99) / 1e6).toFixed(2),
max: (histogram.max / 1e6).toFixed(2),
});
histogram.reset();
}, 5000).unref();
Значения из гистограммы приходят в наносекундах, поэтому делим на 1e6, чтобы получить миллисекунды. Здоровый сервис под нагрузкой держит p99 lag в единицы миллисекунд. Если p99 lag превышает 50–100 мс — loop захлёбывается, и это напрямую отражается на latency ответов.
Что означают цифры
Условия важны: замеры ниже сняты на одном сценарии — сервис принимает JSON, парсит его и синхронно считает хеш. Абсолютные числа зависят от вашего железа и объёма данных, повторяйте замер у себя.
| Сценарий | p99 lag, мс | Что происходит |
|---|---|---|
| Идеальный: только асинхронный I/O | < 5 | poll получает время, соединения принимаются вовремя |
| Синхронный хеш на каждый запрос | десятки–сотни | loop блокируется на время вычисления |
| Рекурсивный nextTick в фоне | растёт неограниченно | poll не выполняется, новые запросы висят |
Как чинить
Синхронный CPU-код — выносить
Криптографию берите в асинхронном варианте: crypto.pbkdf2 вместо crypto.pbkdf2Sync, crypto.scrypt вместо scryptSync. Асинхронные варианты уходят в пул потоков libuv и не блокируют loop.
Тяжёлые чистые вычисления, которые нельзя разбить, выносите в worker_threads — это отдельный поток с собственным loop:
const { Worker } = require('node:worker_threads');
// Возвращает промис с результатом вычисления в отдельном потоке
function runInWorker(inputData) {
return new Promise((resolve, reject) => {
const worker = new Worker('./heavy-task.js', {
workerData: inputData,
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error('worker exit code ' + code));
});
});
}
Длинные синхронные циклы — резать на куски
Если нужно обработать большой массив в одном процессе, разбивайте работу и отдавайте loop передышку через setImmediate — он ставит продолжение в фазу check, давая poll шанс принять новые соединения между порциями. Не используйте для этого process.nextTick: он выполнится до перехода к poll, и передышки не будет.
Не плодить микрозадачи в горячем пути
Каждый лишний await — это точка, где текущий колбэк уступает место опустошению очереди микрозадач. В цикле по большому массиву await на каждой итерации создаёт длинную цепочку. Если операции независимы — собирайте их в Promise.all вместо последовательного await в цикле.
Граничные случаи, где интуиция подводит
- setTimeout(fn, 0) и setImmediate — порядок между ними не гарантирован, если вызваны из основного модуля. Но внутри I/O-колбэка
setImmediateвсегда сработает раньше, потому что фаза check идёт сразу за poll. Не полагайтесь на порядок таймеров для логики. - Пул потоков libuv по умолчанию 4 потока (переменная окружения
UV_THREADPOOL_SIZE). Асинхронная криптография,fsи DNS через него конкурируют. Если завалить пул синхронными по сути задачами, «асинхронные» вызовы начнут стоять в очереди — это выглядит как деградация loop, хотя сам loop свободен. - unhandled rejection в цепочке промисов не блокирует loop, но копит микрозадачи и мешает диагностике. Включайте
--unhandled-rejections=strictв разработке.
Что делать дальше
Повесьте monitorEventLoopDelay в прод и выведите p99 lag в ваш мониторинг рядом с latency ответов — так вы увидите деградацию loop раньше, чем её увидят пользователи. Настройте алерт на p99 lag выше 50 мс. Дальше — профилируйте горячий путь через встроенный --prof или clinic.js, чтобы найти конкретную синхронную операцию, и решайте по ней: асинхронный API, worker_threads или разбиение на порции через setImmediate. Инвалидация и тюнинг UV_THREADPOOL_SIZE — отдельная тема, здесь мы разбирали только поведение самого loop.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: The Node.js Event Loop — официальная документация, perf_hooks: monitorEventLoopDelay — Node.js API docs
Частые вопросы
У меня Node.js 18, а не 20 — замеры и советы актуальны?
Я вынес хеш в асинхронный вариант, но latency всё равно растёт — почему?
process.nextTick или setImmediate — что использовать для передышки в длинном цикле?
Много await в коде — это всегда проблема?
Как отличить блокировку loop от медленной базы данных?
Поможет ли просто добавить воркеров через cluster или PM2?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.