Event Loop в Node.js: фазы и порядок выполнения

✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026

Содержание

Событийный цикл — то, что отличает Node.js от «просто JavaScript на сервере». Понять его порядок один раз — значит перестать удивляться, почему код выполняется не так, как написан.

Что это

Событийный цикл — бесконечный цикл в библиотеке libuv, который на каждом обороте проверяет: какие колбэки готовы выполниться? Ваш JavaScript выполняется в одном потоке, а libuv управляет тем, когда какой колбэк вызвать.

Цикл — не один шаг, а шесть фаз, идущих строго по кругу:

Фаза Что выполняет
timers колбэки setTimeout и setInterval, чей срок вышел
pending callbacks отложенные системные колбэки
idle, prepare внутреннее, для вас невидимо
poll пришедший ввод-вывод: сеть, файлы
check колбэки setImmediate
close callbacks обработчики закрытия: socket.on('close')

Между фазами и после каждого колбэка цикл разбирает две очереди с более высоким приоритетом: process.nextTick и микрозадачи (промисы).

Один оборот событийного цикла
  1. 1 timersСработавшие setTimeout / setInterval
  2. 2 pollВвод-вывод: данные из сети и файлов
  3. 3 checksetImmediate
  4. 4 closesocket.on('close') и подобное
  5. 5 → nextTick + микрозадачиРазбираются ПОСЛЕ каждого шага, а не в конце круга

Порядок выполнения: замер

Теорию проверим. Запланируем всё сразу и посмотрим фактический порядок:

setTimeout(() => console.log('setTimeout(0)'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('промис'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('process.nextTick'));
console.log('синхронный код');
синхронный код -> process.nextTick -> промис -> queueMicrotask -> setImmediate -> setTimeout(0)

Читаем по приоритету:

  1. синхронный код — выполняется весь, до последней строки;
  2. process.nextTick — своя очередь, разбирается первой;
  3. промисы и queueMicrotask — очередь микрозадач, в порядке постановки;
  4. setImmediate — фаза check;
  5. setTimeout(0) — фаза timers.

Почему промис всегда раньше таймера

Это следствие устройства цикла, а не случайность. Очередь микрозадач разбирается полностью после каждого колбэка и между фазами — то есть раньше, чем цикл вообще дойдёт до фазы timers.

Поэтому даже setTimeout(fn, 0) выполнится позже любого промиса: ноль — это минимальная задержка до фазы timers, а не команда «выполни немедленно». Практический вывод: если нужно отложить код на «сразу после текущего», берите queueMicrotask или Promise.resolve().then(), а не setTimeout(0).

Ловушка: порядок nextTick зависит от контекста

Расхожее «process.nextTick всегда первый» верно только на чистом верхнем уровне. Внутри async-функции (после await) порядок меняется: новые микрозадачи попадают в очередь, которая разбирается прямо сейчас, и обгоняют nextTick.

чистый top-level      : nextTick -> промис
после top-level await : промис -> nextTick

Поэтому не стройте логику на приоритете nextTick перед промисами. Он зависит от того, откуда вызван код, а это меняется при любом рефакторинге, добавляющем await выше по стеку. В прикладном коде nextTick почти никогда и не нужен.

Главное следствие: блокировка

Пока выполняется ваш синхронный код, цикл стоит целиком. Ни таймеры, ни ввод-вывод, ни промисы не обрабатываются, пока функция не вернёт управление.

Мы замеряли это на счётном цикле: 234 мс вычислений — и ноль тиков 10-миллисекундного таймера. И на readFileSync: 269 мс — тоже ноль. Природа разная — процессор и диск, — а вывод один: всё, что занимает единственный поток, останавливает весь сервер.

Отсюда правило: тяжёлые вычисления выносят в worker_threads, внешние программы — в дочерний процесс. Событийный цикл силён, пока его не заставляют считать.

Один поток — но пул под капотом

Последнее, что снимает путаницу. «Node однопоточный» — про ваш JavaScript. Но libuv держит пул потоков (по умолчанию 4) для файловых операций и DNS. Поэтому await readFile() не блокирует цикл: чтение с диска выполняет отдельный поток, а ваш код получает управление обратно сразу.

Событийный цикл дирижирует, libuv исполняет ввод-вывод в фоне — вместе они и дают модель «не простаивать на ожидании».


Смежные темы: что такое Node.js — общая картина; промисы и async/await — микрозадачи на практике; таймеры; worker_threads. Полный список — в справочнике по Node.js.

Частые вопросы

Почему промис выполняется раньше setTimeout(0)?
Потому что промисы — микрозадачи, а setTimeout — макрозадача фазы timers. Очередь микрозадач разбирается полностью после каждого колбэка и между фазами, то есть раньше, чем цикл дойдёт до таймеров.
process.nextTick — это микрозадача?
Почти. У nextTick своя очередь с ещё более высоким приоритетом, чем у промисов: она разбирается раньше очереди микрозадач. Но на верхнем уровне это работает так только вне промис-континуации — внутри неё порядок другой.
Чем setImmediate отличается от setTimeout(0)?
setImmediate выполняется в фазе check, setTimeout(0) — в фазе timers. Внутри колбэка ввода-вывода setImmediate всегда раньше; на верхнем уровне порядок между ними не гарантирован.
Блокирует ли тяжёлый код событийный цикл?
Да, полностью. Пока выполняется синхронная функция, цикл стоит и ни один колбэк, таймер или запрос не обрабатывается. Тяжёлые вычисления выносят в worker_threads.
Сколько потоков у событийного цикла?
Сам цикл и ваш JavaScript — один поток. Но libuv держит пул потоков (по умолчанию 4) для файловых операций и DNS, поэтому асинхронный ввод-вывод не блокирует ваш код.