Event Loop — это механизм, с помощью которого один JavaScript-поток координирует множество задач. Синхронный код выполняется прямо сейчас, а таймеры, сетевые и файловые операции и другая асинхронная работа регистрируются и возвращают callbacks, когда готовы. Event Loop снова и снова забирает готовую работу из соответствующих очередей и фаз, поэтому Node может обслуживать много событий, не создавая отдельный JavaScript-поток для каждого из них.
Разбираем: Порядок Event Loop
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
JavaScript-код главного Node-потока следует правилу run-to-completion: начатый участок кода не прерывается другим callback. После обычного callback Node сначала опустошает очередь process.nextTick, затем очередь V8 microtasks — Promise.then и queueMicrotask — и только после этого продолжает фазу или переходит дальше. Timer, I/O callback и setImmediate могут начаться лишь когда готовы и Event Loop дошёл до их timers, poll или check-контекста.
Порядок начала выполнения определяется на границах
Строка регистрирует будущую функцию. Начаться она сможет только после завершения текущего JavaScript и с учётом контекста своей очереди.
- 01 · СтекТекущий JavaScript завершается
Функция или callback выполняется до return. Ни timer, ни Promise, ни I/O не вклинятся в середину синхронного участка.
- 02 · CheckpointПроверяется приоритетная работа
После обычного callback Node опустошает nextTick, затем Promise/queueMicrotask. Добавленная ими работа тоже может попасть в этот checkpoint.
- 03 · Event LoopПродолжается текущая или следующая фаза
Node берёт следующий готовый callback фазы либо движется к poll, check и timers согласно текущему обороту цикла.
Обычно опустошается перед V8 microtasks и может вызвать starvation при рекурсии.
Выполняются на checkpoint до продолжения фаз; новые microtasks тоже опустошаются.
Delay задаёт минимальный порог готовности, а не точный момент старта callback.
Стартует после готовности операции, достижения poll-контекста и освобождения стека.
Ждёт check; не может прервать уже выполняющийся timer или I/O callback.
Sync действительно первый, а nextTick и microtasks получают checkpoint. Но timer, I/O и immediate — не три места глобальной очереди: их относительный старт зависит от фазы, готовности и места регистрации.
В одной уже готовой FIFO-очереди callbacks обычно берутся по порядку добавления. Это нельзя переносить на разные очереди, разные I/O-источники или просто одинаковые значения delay.
timer 1 → nextTick из timer 1 → Promise из timer 1 → timer 2 → setImmediate из timer 1Checkpoint выполняется после timer 1, поэтому приоритетная работа может оказаться между двумя callbacks одной фазы. setImmediate ждёт check и внутрь timers-фазы не вклинивается.
Browser Event Loop и Node Event Loop — родственники, но не близнецы
Оба runtime выполняют JavaScript и Promise jobs по правилам ECMAScript, но окружение определяет, откуда приходит будущая работа. Browser встраивает JavaScript в события страницы и rendering, а Node — в I/O и фазы libuv.
Один callback не прерывается другим JavaScript callback
В одном JavaScript agent действует run-to-completion. Готовность таймера, ответа сети или сообщения ещё не означает немедленный старт: текущий стек должен освободиться, а host должен выбрать эту работу.
- Promise reactions и queueMicrotask выполняются как microtasks на checkpoint.
- Долгий синхронный участок задерживает и обычные callbacks, и microtasks этого agent.
- Параллельный JavaScript появляется только в другом agent: Web Worker, Worker Thread или процесс.
Tasks, microtasks и возможность отрисовки
Главный browser event loop координирует JavaScript страницы, события пользователя и rendering. Это не цикл фаз libuv и не одна глобальная FIFO-очередь.
- Tasks
- Script, click, timer и другие host-события ставят tasks через разные task sources. Порядок внутри одного source сохраняется, но browser выбирает между доступными очередями.
- Microtasks
- После task browser выполняет microtask checkpoint: Promise.then, queueMicrotask и MutationObserver могут опустошить очередь до следующей task.
- Rendering
- При rendering opportunity browser может обновить style/layout, вызвать requestAnimationFrame перед paint и отрисовать кадр. Кадр не обязан появляться после каждой task; фоновые вкладки могут throttling-оваться.
- Web Worker
- Worker получает собственный global scope и event loop, не имеет прямого доступа к DOM и общается через сообщения. Его тяжёлая работа не занимает stack страницы, но конкурирует за CPU.
Очереди Node и фазы libuv без rendering
Node связывает V8 с файловой системой, сетью, процессами и libuv. У server runtime нет DOM, layout, paint и стандартного requestAnimationFrame.
- libuv phases
- Цикл проходит timers, pending callbacks, внутренние idle/prepare, poll, check и close callbacks. setImmediate относится к check, а готовые I/O callbacks обычно обрабатываются вокруг poll.
- nextTick
- process.nextTick — очередь Node, а не фаза libuv и не стандарт browser. После обычного callback она обычно опустошается перед V8 microtasks; рекурсивное пополнение способно задержать I/O.
- I/O и pool
- Сокеты обычно ждут readiness ОС, а часть fs, DNS, crypto и zlib использует ограниченный libuv thread pool. Готовый результат всё равно возвращает callback в JavaScript Event Loop.
- Worker Thread
- Worker Thread создаёт отдельный V8 isolate, stack и Event Loop внутри процесса. Он подходит для CPU-bound JavaScript; сообщения в main становятся асинхронными callbacks.
Сначала завершается callback, затем host применяет собственные правила
Оба фрагмента показывают run-to-completion и microtask checkpoint. Отличающиеся API показывают именно host-часть, которой нет в самом языке JavaScript.
button.addEventListener('click', () => {
console.log('handler');
queueMicrotask(() => console.log('microtask'));
requestAnimationFrame(() => console.log('rAF'));
setTimeout(() => console.log('timer'), 0);
});Сначала handler, после его возврата — microtask. rAF выполнится перед paint выбранного кадра, а timer — как отдельная будущая task; универсального порядка между rAF и timer нет.
readFile(new URL(import.meta.url), () => {
console.log('I/O');
process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('microtask'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timer'), 0);
});В этом I/O-контексте: I/O → nextTick → microtask → immediate → timer. immediate попадает в ближайшую check-фазу, новый timer ждёт следующего timers-контекста.
- Promise и queueMicrotask относятся к общей JavaScript-модели jobs, но момент checkpoint встраивает host.
- requestAnimationFrame описывает rendering browser; setImmediate и process.nextTick — специфичные API Node.
- Web Worker и Worker Thread решают похожую задачу изоляции работы, но имеют разные API и окружение.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Call Stack
Стек вызываемых сейчас функций. Пока он не пуст, другой callback не начнёт выполнять JavaScript.
Callback
Функция, которую runtime вызовет позже после события: таймера, I/O, сообщения Worker и т. п.
Microtask
Приоритетное продолжение Promise или queueMicrotask. Очередь очищается между callbacks и фазами.
Фаза
Этап оборота libuv Event Loop. Для лаборатории важны timers, poll и check.
Регистрация
Синхронный момент, когда runtime получает callback и условия его будущего запуска. Регистрация ещё не является выполнением callback.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Выполняется sync-код
Строки читаются сверху вниз: console.log печатает сразу, а остальные вызовы регистрируют callbacks.
- 02Стек освобождается
Только теперь другой callback получает право начать JavaScript. Это граница выбора, а не прерывание текущей функции.
- 03Приоритетный checkpoint
После обычного callback Node опустошает process.nextTick, затем Promise.then/queueMicrotask. Но top-level ESM и уже выполняющийся microtask — отдельные контексты.
- 04Выбирается готовая работа фазы
Timers проверяет достигнутые пороги, poll обрабатывает готовое I/O, check — setImmediate. Между ними нет универсального глобального FIFO.
- 05После callback правило повторяется
После каждого завершившегося callback снова наступает checkpoint. Поэтому nextTick и Promise из timer 1 могут стартовать раньше timer 2.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Порядок строк — это порядок регистрации
Сначала действительно выполняется строка nextTick, затем строка Promise и так далее. Но тела стрелочных функций выполнятся позже. Поэтому порядок строк не равен итоговому порядку console.log.
FIFO действует после проверки контекста
Два Promise.then, зарегистрированные подряд, сохраняют порядок. Два уже готовых однотипных callback одной фазы обычно тоже берутся по порядку очереди. Но это не правило для разных фаз, источников I/O или работ с разной готовностью.
Timer, immediate и I/O не образуют лестницу
В main-модуле setTimeout(0) и setImmediate могут поменяться местами. Если оба созданы внутри одного I/O callback, setImmediate выполняется раньше нового timer. Сам I/O начнётся тогда, когда операция станет готова и Event Loop сможет обработать её callback.
nextTick → Promise тоже требует контекста
После обычного callback и в CommonJS nextTick идёт перед Promise microtasks. ES-модуль вычисляется как microtask; если планирование происходит в top-level ESM или уже внутри microtask, Promise/queueMicrotask могут появиться раньше nextTick до возврата управления Node.
Версия Node влияет на старые схемы
Начиная с libuv 1.45 / Node 20 timers запускаются только после poll, а не до и после него. Поэтому диаграмма из старой статьи может не совпасть с современной Node.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
console.log('sync'); // Выполняется сейчас
// Эти строки регистрируют callbacks сверху вниз:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
// Их тела выполнятся позже по правилам очередей и фаз.Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
import { readFile } from 'node:fs';
import { fileURLToPath } from 'node:url';
const packageJsonPath = fileURLToPath(
new URL('../package.json', import.meta.url),
);
const sleep = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
async function eventLoopOrder(emit) {
emit('call-stack', 'sync', 'Синхронный код начал выполняться');
const outerCallbacks = [];
const waitForOuter = new Promise((resolve) => {
let completed = 0;
const done = () => {
completed += 1;
if (completed === 4) resolve();
};
process.nextTick(() => {
outerCallbacks.push('process.nextTick');
emit('nextTick', 'callback', 'process.nextTick callback');
done();
});
Promise.resolve().then(() => {
outerCallbacks.push('Promise.then');
emit('microtasks', 'callback', 'Promise.then microtask');
done();
});
queueMicrotask(() => {
outerCallbacks.push('queueMicrotask');
emit('microtasks', 'callback', 'queueMicrotask callback');
done();
});
setTimeout(() => {
outerCallbacks.push('setTimeout(0)');
emit('timers', 'callback', 'setTimeout(0) callback');
done();
}, 0);
setImmediate(() => {
outerCallbacks.push('setImmediate');
emit('check', 'callback', 'setImmediate callback');
done();
});
});
emit(
'call-stack',
'schedule',
'Callbacks зарегистрированы; синхронный стек сейчас освободится',
);
await waitForOuter;
// Ждём timer и immediate. Начальный порядок зависит от того, из какого
// async-контекста вызывается сценарий, поэтому мы записываем наблюдение,
// а не подгоняем его под заученную последовательность.
while (
!outerCallbacks.includes('setTimeout(0)') ||
!outerCallbacks.includes('setImmediate')
) {
await sleep(1);
}
emit('result', 'result', `Контекст запуска: ${outerCallbacks.join(' → ')}`);
emit('poll', 'schedule', 'Запускаем fs.readFile и переходим к I/O-раунду');
await new Promise((resolve, reject) => {
readFile(packageJsonPath, 'utf8', (error) => {
if (error) {
reject(error);
return;
}
emit('poll', 'callback', 'Callback fs.readFile: сейчас мы внутри poll-фазы');
const ioOrder = [];
let completed = 0;
const done = () => {
completed += 1;
if (completed === 4) {
emit('result', 'result', `Внутри I/O: ${ioOrder.join(' → ')}`);
resolve();
}
};
process.nextTick(() => {
ioOrder.push('nextTick');
emit('nextTick', 'callback', 'nextTick, созданный внутри I/O');
done();
});
Promise.resolve().then(() => {
ioOrder.push('Promise');
emit('microtasks', 'callback', 'Promise, созданный внутри I/O');
done();
});
setImmediate(() => {
ioOrder.push('setImmediate');
emit('check', 'callback', 'setImmediate, созданный внутри I/O');
done();
});
setTimeout(() => {
ioOrder.push('setTimeout');
emit('timers', 'callback', 'setTimeout(0), созданный внутри I/O');
done();
}, 0);
});
});
}Live trace — прикладное инструментирование сценария. Строки и отметки времени появляются в реальные моменты вызова emit(...) внутри callbacks, поэтому наблюдаемый порядок относится к настоящему запуску. Названия source и lane передаёт сам код сценария: это не profiler V8/libuv и не прямой снимок их внутренних очередей или фаз.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
Microtasks между двумя timers
Увидеть checkpoint после каждого callback, а не только после завершения всей timers-фазы.
setTimeout(() => {
console.log('timer 1');
process.nextTick(() => console.log('nextTick from timer 1'));
Promise.resolve().then(() => console.log('Promise from timer 1'));
setImmediate(() => console.log('immediate from timer 1'));
}, 0);
setTimeout(() => console.log('timer 2'), 0);- Если оба таймера уже готовы в одной timers-фазе: timer 1 → nextTick → Promise → timer 2 → immediate.
- nextTick и Promise стартуют после возврата из callback timer 1.
- setImmediate ждёт check, поэтому не прерывает обработку timers-фазы.
Immediate и timer внутри I/O
Зафиксировать контекст, в котором относительный порядок предсказуем.
import { readFile } from 'node:fs';
readFile(new URL(import.meta.url), () => {
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
});
// Здесь: immediate → timer- readFile callback обрабатывается в I/O-контексте poll.
- После poll Event Loop достигает check, поэтому новый immediate выполняется раньше нового нулевого timer.
- Тот же вывод нельзя бездумно переносить на top-level main-модуля.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Событие заказа публикуется раньше обязательного аудита
Checkout endpoint сохраняет заказ, регистрирует публикацию события через setImmediate и затем ждёт запись аудита. Автор рассчитывал, что строки исходника задают порядок всех действий.
После await стек освобождается. Пока audit.write ждёт I/O, check-фаза может выполнить setImmediate, и downstream consumer увидит OrderCreated раньше обязательной audit-записи.
async function checkout(input) {
const order = await orders.insert(input);
setImmediate(() => {
broker.publish('OrderCreated', order);
});
await audit.write('order.created', order.id);
return order;
}Порядок строк определяет регистрацию, но callback setImmediate и продолжение await попадают в разные механизмы планирования. Между ними нет бизнес-гарантии порядка.
async function checkout(input) {
const order = await orders.insert(input);
await audit.write('order.created', order.id);
await broker.publish('OrderCreated', order);
return order;
}
// Если нужна атомарность с INSERT:
// order + outbox event записываются
// одной DB-транзакцией.Обязательная зависимость выражена через await. Для гарантии «заказ и событие вместе» используется transactional outbox, а не порядок фаз Event Loop.
Что делают непривычные вызовы из обоих фрагментов кода.
orders.insert(input)- Асинхронный метод репозитория: записывает заказ в БД и возвращает Promise с созданным заказом.
setImmediate(callback)- Регистрирует callback для check-фазы Event Loop. Он не означает «выполни строго после следующей строки».
await promise- Приостанавливает только текущую async-функцию. После settlement её продолжение будет поставлено в очередь microtasks.
broker.publish(...)- Отправляет событие брокеру сообщений. В корректном API возвращает Promise, чтобы вызывающий код мог дождаться подтверждения.
transactional outbox- Заказ и запись о будущем событии сохраняются одной DB-транзакцией, а отдельный publisher позднее отправляет событие.
Популярные заблуждения
Миф слева, корректная модель справа.
В Node существует одна общая очередь событий.
Очередей и фаз несколько, между ними есть правила приоритета.
setTimeout(fn, 0) выполняет fn немедленно.
Ноль означает минимальную задержку; callback ещё должен дождаться подходящей фазы и свободного стека.
Асинхронная функция может прервать текущий JavaScript.
Нет: callback начнётся только после завершения текущего участка кода.
Если строка записана выше, её callback обязательно сработает раньше.
Это верно лишь при совместимых условиях, например внутри одной FIFO-очереди. Между очередями сначала применяются их приоритеты и правила фаз.
process.nextTick всегда и везде раньше Promise.
Так происходит в обычных callbacks и CommonJS. При вычислении top-level ESM Promise/microtask может получить преимущество.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему Promise не выполняется в момент вызова Promise.resolve()?
- Что произойдёт с таймером, если текущая функция работает пять секунд?
- Когда порядок строк снова становится порядком callbacks, а когда его переопределяет очередь?