27 июл. 2026 г.

Event Loop в Node.js: как я объясняю его себе на практике

Event loop важен не для собеседований, а для понимания задержек, блокировок, очередей, таймеров, Promise microtasks и поведения backend под нагрузкой.

Event loop часто объясняют через картинки с кругами, стрелками и фазами. Это полезно, но у меня долго оставалось ощущение, что я понимаю схему, но не до конца понимаю последствия.

На практике event loop важен не для того, чтобы правильно ответить на вопрос на собеседовании. Он важен для другого: понимать, почему backend “подвисает”, почему setTimeout(..., 0) не выполняется сразу, почему Promise callbacks могут задержать timers, почему CPU-heavy код ломает отзывчивость Node.js и почему очередь задач - это не магия параллельности.

Node.js не делает JavaScript параллельным

JavaScript-код в Node.js выполняется в одном основном потоке. Если этот поток занят долгим синхронным вычислением, event loop не может перейти к следующей задаче.

Простой пример:

setTimeout(() => {
  console.log('timer');
}, 0);

const startedAt = Date.now();

while (Date.now() - startedAt < 3000) {
  // blocking work
}

console.log('sync finished');

timer не выполнится через 0 миллисекунд. Он выполнится только после того, как освободится call stack. Это ключевая мысль: event loop не прерывает текущий JavaScript-код. Он ждет, пока синхронная работа закончится.

Что тогда делает event loop

Event loop координирует выполнение callbacks из разных источников:

  • timers: setTimeout, setInterval;
  • I/O callbacks: сеть, файловая система, сокеты;
  • poll: ожидание новых I/O событий;
  • check: setImmediate;
  • close callbacks: закрытие сокетов и ресурсов;
  • microtasks: Promise.then, queueMicrotask;
  • process.nextTick, который в Node.js имеет особый приоритет.

Грубая модель такая: синхронный код выполняется сразу, затем Node.js разбирает microtasks, затем переходит между фазами event loop и берет callbacks из соответствующих очередей.

Microtasks могут быть опаснее, чем кажутся

Promise callbacks обычно воспринимаются как “почти сразу после текущего кода”. Это верно, но у этого есть обратная сторона: длинная цепочка microtasks может задержать переход event loop к timers и I/O.

function spin(count: number) {
  if (count === 0) return;
  Promise.resolve().then(() => spin(count - 1));
}

setTimeout(() => console.log('timer'), 0);
spin(100_000);

Таймер будет ждать, пока очередь microtasks не опустеет. В реальном backend такое может проявляться не так явно: много Promise-цепочек, сериализация больших данных, сложные synchronous callbacks после await - и latency начинает расти.

process.nextTick еще чувствительнее

В Node.js process.nextTick выполняется раньше обычных Promise microtasks. Это удобно для некоторых низкоуровневых API, но злоупотребление может буквально “морить голодом” event loop.

function loop() {
  process.nextTick(loop);
}

loop();

Такой код не даст event loop нормально перейти к I/O. В прикладном коде я стараюсь не использовать process.nextTick без очень понятной причины.

setTimeout(0) и setImmediate

Один из классических вопросов: что выполнится раньше - setTimeout(..., 0) или setImmediate?

Ответ зависит от контекста. На верхнем уровне порядок может быть неочевидным. Внутри I/O callback setImmediate обычно выполнится раньше timer'а, потому что он попадает в check phase после poll.

Но в прикладной разработке важнее не заучить “кто первый”, а понимать намерение:

  • setTimeout - выполнить не раньше указанной задержки;
  • setImmediate - отложить выполнение до check phase;
  • queueMicrotask - выполнить после текущего call stack до перехода к новым macrotasks.

Главный враг backend-а - blocking code

Node.js хорошо подходит для I/O-heavy задач: API, запросы к базе, сеть, очереди, файловые операции, realtime. Но если положить в request handler тяжелую синхронную работу, весь процесс начнет хуже отвечать на другие запросы.

Типичные проблемы:

  • большой JSON parse/stringify;
  • синхронная криптография;
  • генерация больших документов в основном потоке;
  • обработка изображений;
  • сложные циклы по большим массивам;
  • регулярные выражения с catastrophic backtracking;
  • синхронные filesystem операции.

Если задача CPU-heavy, лучше подумать про worker threads, отдельный сервис, очередь или вынос обработки из пользовательского запроса.

Как я применяю это в backend

Когда я проектирую endpoint, я задаю несколько вопросов:

  • есть ли внутри тяжелая синхронная работа;
  • можно ли вернуть ответ раньше, а обработку вынести в job;
  • не будет ли Promise-цепочка слишком длинной;
  • можно ли стримить данные вместо сборки всего результата в памяти;
  • что случится с latency, если таких запросов будет много одновременно;
  • достаточно ли логов, чтобы увидеть event loop lag.

Event loop - это не абстрактная тема из JavaScript. Это часть эксплуатационного поведения backend-а.

Вывод

Понимать event loop - значит понимать границу между “асинхронно” и “параллельно”. await не делает CPU-bound код безопасным. Promise не отменяет blocking. Таймер не гарантирует выполнение ровно через указанное время.

Для backend-разработчика это практическое знание: оно помогает проектировать очереди, не блокировать request handlers, объяснять latency и выбирать правильное место для тяжелых задач.

Комментарии

Пока нет комментариев — будьте первым.