Event Loop в Node.js: что реально спрашивают на мидл- и сеньор-интервью

21 октября 2026 г. · javascript, nodejs, interview, architecture

Обычный JS-код в Node.js выполняется в одном потоке. Строго в одном, как бы ни пытались запутать ревьюеры :) Обработчик запроса занялся вычислениями на секунду — остальные JS-обработчики в этом потоке ждут. Даже если ты написал async перед функцией.

С асинхронными операциями иначе. Ждём сеть или чтение файла — JS-поток свободен. Занимай его чем хочешь. Результат готов — event loop вернёт его в обработчик.

На собеседовании разговор обычно стартует с «микротаски раньше макротасков». На простом примере этого ещё хватает. Дальше начинаются детали: что здесь спецификация языка, почему process.nextTick обгоняет промисы — и всегда ли. А потом вопрос из практики: сервис тормозит, и не из-за I/O, а из-за твоего же синхронного кода. Что делать?

Это вторая часть разбора. Здесь — специфика Node.js: nextTick, фазы event loop и блокировки. Браузерную модель, очереди задач и рендеринг разбираю отдельно — «Event Loop в браузере».

А что тут от JavaScript, а что от Node.js?

Event loop в спецификации ECMAScript не описан. Его делает хост-окружение — браузер или Node.js.

С промисами иначе. Promise — часть языка, а его обработчики идут через механизм Jobs из ECMAScript. Хост лишь встраивает этот механизм в свой порядок выполнения.

В Node.js работа поделена: V8 выполняет JavaScript и ведёт очередь микрозадач, Node — свою очередь process.nextTick, libuv — основу цикла и I/O. Поэтому «V8 вообще ничего не знает про event loop» — грубовато. Цикл он не организует, но микрозадачами занимается.

Как отвечать коротко: event loop и фазы Node.js — не часть спецификации языка. Promise и механизм Jobs — часть ECMAScript. Как это состыковать, решает окружение.

Почему модели «две очереди» не хватает

На простых примерах хватает двух слов — микрозадачи и макрозадачи:

  • обработчики промисов и queueMicrotask попадают в очередь микрозадач;
  • таймеры, I/O-колбэки и setImmediate выполняются в рамках event loop.

Но в Node.js нет одной общей очереди макрозадач, откуда на каждом витке берут ровно один колбэк. Есть фазы. И за одну фазу может выполниться сразу несколько колбэков.

Отдельно живёт очередь process.nextTick. Это API Node.js, не ECMAScript.

Рабочая модель для обычного колбэка такая:

  1. Выполняется синхронный код колбэка.
  2. Node опустошает очередь nextTick.
  3. Затем обрабатывает очередь микрозадач, включая добавленные во время её обработки.
  4. Если микрозадачи добавили новые nextTick, Node обрабатывает их перед возвращением к обычной работе цикла.

Важная деталь: nextTick, созданный внутри микрозадачи, не прерывает уже идущую обработку микрозадач. Здесь и ошибаются — когда помнят только «nextTick всегда первый».

А nextTick — это что, более «официальный» механизм?

Нет. process.nextTick() есть только в Node.js. То, что его очередь обычно обрабатывается раньше промисов, ещё не делает его «более стандартным».

Приоритет — одно. Принадлежность к спецификации — другое. Node добавил свой API и свои правила.

Причём «nextTick раньше промисов» — правило с оговоркой. На верхнем уровне CommonJS это работает. А верхний уровень ES-модуля уже выполняется в контексте микрозадач, поэтому там порядок другой — не из-за гонки, а из-за контекста выполнения.

Посмотрим на примере.

Пример, который стоит уметь разобрать без запуска

Этот код запускаем как CommonJS — например, в файле example.cjs.

console.log('A');

setTimeout(() => console.log('B'), 0);

process.nextTick(() => {
   console.log('C');
});

Promise.resolve()
   .then(() => {
      console.log('D');

      process.nextTick(() => {
         console.log('E');
      });
   })
   .then(() => {
      console.log('F');
   });

queueMicrotask(() => {
   console.log('G');
});

async function fn() {
   console.log('H');
   await null;
   console.log('I');
}

fn();

console.log('J');

Результат:

A H J C D G I F E B

Откуда взялся этот порядок

Сначала синхронный код: A, потом H внутри fn(), потом J.

await null функцию синхронно не продолжает. Даже если после await обычное значение, продолжение всё равно откладывается.

После синхронного прохода очереди такие:

nextTick:    [C]
микрозадачи: [D, G, I]

Первым идёт C. Потом начинаются микрозадачи.

Берём D:

  • печатает D;
  • кладёт E в очередь nextTick;
  • после завершения обработчика промис, возвращённый первым .then, резолвится, и F встаёт в конец очереди микрозадач.

Теперь так:

nextTick:    [E]
микрозадачи: [G, I, F]

И вот тут Node на E не перескакивает. Начатая обработка микрозадач идёт до конца: G, I, F. И только потом — E.

Последним срабатывает таймер — B.

А если запустить как ES-модуль?

Тот же код в .mjs — порядок другой:

A H J D G I F C E B

Верхний уровень ES-модуля уже выполняется в контексте микрозадач. Сначала они дорабатывают до конца, и только потом Node берётся за nextTick: сначала C, потом E.

Отсюда практический совет. Попалась задача на порядок вывода — уточни не только версию Node.js, но и способ запуска: CommonJS или ESM. Здесь это влияет на ответ.

Фазы event loop и setTimeout против setImmediate

nextTick и микрозадачи фазы event loop не заменяют. Они разбираются в контрольных точках — между обычными JS-колбэками.

А фаз, которые нужны в повседневной работе, пять:

  • timers — колбэки таймеров, для которых наступил порог задержки;
  • pending callbacks — некоторые отложенные системные колбэки;
  • poll — обработка I/O-событий и ожидание новых, когда это уместно;
  • check — колбэки setImmediate;
  • close callbacks — некоторые колбэки закрытия, например события close сокета.

Список упрощённый: внутренние фазы вроде idle и prepare пропускаю. И оговорка для старых схем: начиная с Node.js 20 (libuv 1.45) таймеры запускаются не в начале витка, а в конце — после poll. Так что timers первым в списке — скорее традиция схем, чем точный порядок.

Теперь классика:

const fs = require('node:fs');

fs.readFile(__filename, () => {
   setTimeout(() => console.log('timeout'), 0);
   setImmediate(() => console.log('immediate'));
});

Здесь порядок:

immediate
timeout

Оба вызова внутри этого I/O-колбэка — setImmediate выполнится раньше таймера. А вне I/O-колбэка, например на верхнем уровне файла, на этот порядок полагаться нельзя.

И да: setTimeout(fn, 0) — это не «сейчас». Это таймер с минимальной задержкой, а не обещание точного времени запуска.

Может ли nextTick оставить I/O без шансов?

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

again();

Очередь пополняется бесконечно, а Node обязан опустошить её перед возвращением к event loop. До I/O-колбэков и таймеров дело не дойдёт.

С бесконечной цепочкой микрозадач — похожая история. Высокий приоритет не бесплатный: всё время планируешь работу «перед остальными» — остальные ждут.

Поэтому nextTick — не замена setImmediate для любого «сделать потом». Нужно просто отложить вызов через микрозадачу — документация Node для большинства пользовательских сценариев советует queueMicrotask. Нужно дать циклу дойти до I/O — микрозадача тут не поможет.

А если блокирует не очередь, а сам код?

Всё выше — про порядок. Но порядок не поможет, пока текущая синхронная функция не вернула управление.

Большой JSON.stringify, тяжёлая регулярка, синхронное хеширование, обычный цикл — любой из них держит JS-поток сколько захочет. Остальные обработчики ждут. Таймеры тоже.

В миниатюре:

const t0 = Date.now();

setInterval(() => {
   console.log('tick', Date.now() - t0);
}, 100);

setTimeout(() => {
   const start = Date.now();

   while (Date.now() - start < 1000) {
      // Имитируем тяжёлую синхронную работу.
   }
}, 250);

Примерный вывод:

tick 102
tick 202
tick 1251
tick 1352

Числа у тебя будут другие. Провал — тот же.

Пока поток занят, ОС может продолжать принимать сетевые данные, а фоновые операции — завершаться. Но JS-обработчики в этом потоке всё равно ждут. Для клиента это просто зависший сервер.

Заодно развею миф: асинхронный I/O не значит «всё уехало в пул потоков libuv». Сетевые сокеты обычно обслуживаются неблокирующим I/O через механизмы ОС. Пул нужен для другого — многих файловых операций и некоторых криптографических.

Так что вопрос теперь не «какая очередь первая», а «куда деть работу, которая занимает поток». Тут уже мало помнить порядок вывода — придётся обсуждать устройство сервиса.

worker_threads

Воркеры гоняют JavaScript параллельно, в отдельных потоках. Для CPU-bound задачи это способ освободить основной поток — и не выносить вычисление в отдельный сервис.

const {
   Worker,
   isMainThread,
   parentPort,
   workerData,
} = require('node:worker_threads');

if (isMainThread) {
   const worker = new Worker(__filename, {
      workerData: 45,
   });

   worker.on('message', (result) => {
      console.log('fib:', result);
   });

   worker.on('error', console.error);
} else {
   const fib = (n) => (
      n < 2 ? n : fib(n - 1) + fib(n - 2)
   );

   parentPort.postMessage(fib(workerData));
}

Fibonacci здесь намеренно тупой. Нужна задача, которая заметно нагрузит CPU.

Пока воркер считает, основной поток обслуживает другие запросы. Но воркеру надо передать данные и забрать результат. С большими объектами этот обмен тоже чего-то стоит.

Для регулярных вычислений нужен пул воркеров, а не новый поток на каждый запрос. А с пулом приходят вполне земные вопросы: размер очереди, предел параллелизма, ошибки, поведение при перегрузке.

cluster и несколько процессов

Другой вариант — запустить несколько экземпляров приложения.

Один процесс занят вычислением — остальные продолжают отвечать. Но главный процесс раздаёт соединения по кругу и не знает, что воркер занят. Так что подождёт примерно каждый N-й новый запрос — плюс все keep-alive соединения, которые уже привязаны к этому процессу. И одно тяжёлое вычисление само не распараллелится.

Тут легко перепутать две разные задачи:

  • добавить приложению пропускной способности;
  • убрать тяжёлую работу из обработчика запроса.

Несколько процессов помогают с первой. Со второй — не обязательно.

И ещё состояние. Локальный кэш и сессии в памяти не станут общими только потому, что процессы живут на одном сервере. Где-то независимые копии сойдут. Где-то понадобится внешнее хранилище.

Отдельный воркер и очередь

Результат не нужен прямо в HTTP-ответе? Тогда работу можно вынести из запроса:

  1. API принимает задачу.
  2. Кладёт её в очередь.
  3. Воркер выполняет вычисление.
  4. Клиент получает результат отдельно или проверяет статус.

Вычислительная нагрузка отдельно, API отдельно. Воркеры масштабируются сами по себе, а при желании там можно взять другой язык или библиотеку.

Но очередь — не бесплатная магия. Теперь надо решать, что будет при повторной доставке задачи, при падении воркера, при переполнении очереди, при повторном запросе клиента.

А ещё API и воркеры всё равно делят инфраструктуру. CPU, база, сеть. Появилась очередь — не значит, что API больше никогда не затормозит.

Я бы не привязывал выбор к числу запросов в секунду. Один тяжёлый запрос способен наделать больше бед, чем тысяча лёгких. Важнее другое: сколько стоит задача, какая задержка допустима и нужен ли результат прямо сейчас.

Чанкинг: а может, просто отдавать управление?

Не всё сразу надо выносить в другой поток. Иногда работу можно порезать на небольшие части — и между ними возвращать управление event loop.

async function processAll(items) {
   for (let i = 0; i < items.length; i += 1000) {
      const end = Math.min(i + 1000, items.length);

      for (let j = i; j < end; j++) {
         heavyStep(items[j]);
      }

      await new Promise((resolve) => {
         setImmediate(resolve);
      });
   }
}

setImmediate здесь нужен именно чтобы отдать управление циклу. Заменишь на await Promise.resolve() — продолжения пойдут через микрозадачи. Между чанками I/O хода не получит: сначала отработает весь for.

Параллелизма не появилось. Вычисление сидит в том же потоке — просто больше не держит его одним длинным куском.

И тысяча элементов — не волшебный размер чанка. Если один heavyStep съедает 100 мс, проблема уже внутри шага. Подбирать надо по времени выполнения, а не по красивому числу.

Потоковая обработка (streams) тоже выручает, когда не хочется грузить весь объём разом. Но тяжёлый синхронный обработчик она сама не сделает неблокирующим.

Если тормозит нативная зависимость

Сначала выяснить, где вообще выполняется работа.

Библиотека может отдавать асинхронный API и работать в фоновых потоках. А может синхронно занять твой. Слово «нативный» ничего не гарантирует.

Так что я бы начинал с документации конкретной библиотеки и профиля нагрузки. Иначе легко начать перекладывать код между потоками, хотя узкое место совсем в другом месте. Давать оценку без изучения самой библиотеки — дело неблагодарное :)

Как выбрать

Сначала убедиться, что блокирует именно вычисление. Не медленная база. Не внешний API. Не очередь соединений.

Дальше я бы смотрел на несколько вещей:

  • Результат нужен в текущем ответе или задачу можно выполнить в фоне?
  • Это редкая тяжёлая операция или постоянная часть нагрузки?
  • Можно ли разбить работу на короткие шаги?
  • Сколько данных придётся передавать воркеру?
  • Что произойдёт, если таких задач одновременно придёт не одна, а сто?
  • Есть ли уже очередь и инфраструктура для фоновых задач?

После этого картина обычно проясняется:

  • Чанкинг — работу можно дробить, и хватит просто сохранить отзывчивость.
  • Пул worker_threads — нужен параллельный запуск CPU-bound кода внутри приложения.
  • Несколько процессов — надо обслуживать больше независимых запросов.
  • Отдельные воркеры с очередью — задачу можно отделить от HTTP-запроса и управлять ею отдельно.

По этому списку не обязательно идти как по лестнице. Отчёт собирается минуту и не требует открытого соединения? Очередь тут будет проще, чем пытаться удержать всё внутри запроса.

Но начинать всё равно с профиля и небольшого нагрузочного теста. Воркеры, добавленные наугад, вполне способны не убрать узкое место, а просто переселить его.

Коротко (TL;DR)

  • Event loop Node.js не описан в ECMAScript. Промисы и механизм Jobs — описаны.
  • В Node.js есть фазы, а не одна очередь макрозадач с правилом «одна задача за виток».
  • nextTick обычно выполняется перед микрозадачами, но не прерывает их уже начавшуюся обработку. Верхний уровень ESM — отдельная важная оговорка.
  • await откладывает продолжение даже для обычного значения.
  • Внутри I/O-колбэка setImmediate выполняется раньше запланированного там же setTimeout(..., 0). На верхнем уровне на этот порядок полагаться нельзя.
  • Бесконечное пополнение nextTick или микрозадач может не дать циклу вернуться к I/O.
  • Если поток занят синхронным вычислением, перестановка очередей не поможет. Нужно сокращать работу, дробить её или выполнять в другом потоке либо процессе.

💬  Обсудить статью можно в Telegram-канале →