Событийный цикл в JavaScript: почему код исполняется не по порядку
setTimeout с нулём отработал позже, чем промис, добавленный после него. Разбираем очереди задач и микрозадач в V8, чтобы вы перестали угадывать порядок вывода.

Классическая ловушка: вы пишете console.log('a'), потом setTimeout(() => console.log('b'), 0), потом Promise.resolve().then(() => console.log('c')), потом console.log('d'). Ожидаете a b c d, а получаете a d c b. Ноль в setTimeout не сделал колбэк мгновенным, а промис обогнал таймер, добавленный раньше него. Разберём, почему так, и как перестать угадывать порядок исполнения.
Статья про поведение событийного цикла в V8 — движке Node.js (18.x и новее) и Chromium. В браузере на других движках нюансы отличаются, о них скажу отдельно. Речь только о порядке исполнения задач и микрозадач. Внутреннее устройство libuv в Node.js — отдельная тема, здесь не разбираем.
Что вообще исполняет ваш код
JavaScript однопоточный: в один момент времени выполняется ровно один кусок синхронного кода. Пока он не закончится, ничто другое не начнётся. Всё «асинхронное» — таймеры, сетевые ответы, промисы — не выполняется параллельно. Оно откладывается в очереди, а движок разбирает эти очереди в строго заданном порядке, когда основной стек опустеет.
Ключевых сущностей три:
- Стек вызовов — то, что исполняется прямо сейчас. Синхронный код от начала до конца.
- Очередь макрозадач (task queue) — сюда попадают колбэки
setTimeout,setInterval, обработчики событий DOM, в Node.js — колбэки I/O. - Очередь микрозадач (microtask queue) — сюда попадают колбэки
.then/.catch/.finallyу промисов,await,queueMicrotaskи коллбэкиMutationObserverв браузере.
Правило, из которого вытекает всё остальное: после каждой макрозадачи движок полностью опустошает очередь микрозадач, и только потом берёт следующую макрозадачу.
Микрозадачи не «быстрее» макрозадач. Они просто выполняются раньше — сразу после текущего синхронного блока и перед любым следующим таймером. Даже если таймер поставлен на 0 миллисекунд.
Разбор того самого примера
console.log('a'); // синхронно
setTimeout(() => console.log('b'), 0); // макрозадача
Promise.resolve().then(() => console.log('c')); // микрозадача
console.log('d'); // синхронноПорядок вывода: a d c b. Логика движка:
- Исполняется весь синхронный код: печатается
a, регистрируется таймер, регистрируется.then, печатаетсяd. Стек пуст. - Движок опустошает очередь микрозадач — печатается
c. - Микрозадач больше нет, берётся макрозадача — печатается
b.
Как async/await ложится на эти очереди
Ключевое, что путает: await — это не пауза потока. Всё до первого await в функции выполняется синхронно, а всё, что после await, превращается в микрозадачу, поставленную в очередь после разрешения ожидаемого значения.
async function run() {
console.log('1'); // синхронно, при вызове run()
await null; // точка разрыва
console.log('2'); // микрозадача
}
console.log('start');
run();
console.log('end');
// Вывод: start 1 end 2Строка 1 печатается сразу, потому что до await тело функции синхронно. После await управление возвращается в вызывающий код, печатается end, и только когда стек опустеет — микрозадача с 2.
Почему это ломает интуицию про циклы
Ошибка, которую делают почти все: думают, что await внутри forEach выстроит операции последовательно.
// НЕ работает как ожидается: forEach не ждёт промисов
[1, 2, 3].forEach(async (id) => {
await saveToDb(id); // все три запускаются почти одновременно
});
console.log('готово'); // напечатается раньше любого saveToDb
// Работает: обычный for с await ждёт каждую итерацию
async function saveAll(ids) {
for (const id of ids) {
await saveToDb(id);
}
console.log('готово');
}forEach не смотрит на возвращаемый промис колбэка, поэтому «ожидания» не происходит. Обычный for...of с await честно приостанавливает итерацию.
Node.js против браузера: где поведение расходится
В браузере всё просто: одна очередь макрозадач, одна микрозадач. В Node.js событийный цикл libuv делится на фазы (таймеры, I/O-колбэки, poll, check и другие), а между переходами по фазам движок тоже опустошает микрозадачи.
Ещё в Node.js есть process.nextTick() — его колбэки выполняются раньше обычных промис-микрозадач. Это отдельная очередь с приоритетом. В браузере такого API нет.
| Механизм | Тип | Когда исполнится | Где доступен |
|---|---|---|---|
process.nextTick | микро (приоритет) | раньше промисов, после текущей операции | только Node.js |
Promise.then / await | микрозадача | после синхронного кода, перед макрозадачами | везде |
queueMicrotask | микрозадача | в порядке добавления, после промисов из того же тика | везде |
setTimeout(fn, 0) | макрозадача | в фазе таймеров, после всех микрозадач | везде |
setImmediate | макрозадача | в фазе check, обычно после I/O | только Node.js |
Порядок между setTimeout(fn, 0) и setImmediate в Node.js на верхнем уровне модуля не гарантирован — зависит от того, насколько быстро цикл дошёл до фазы таймеров. Внутри же I/O-колбэка setImmediate сработает раньше setTimeout стабильно. Если поведение важно — не полагайтесь на порядок этих двух вне I/O-контекста.
Что реально ломается на практике
- Бесконечная микрозадача блокирует всё. Если микрозадача рекурсивно ставит новую микрозадачу (
Promise.resolve().then(loop)), движок не перейдёт к макрозадачам и таймерам — очередь микрозадач опустошается полностью перед каждой макрозадачей. Интерфейс в браузере зависнет, таймеры не сработают. - Тяжёлый синхронный цикл держит поток. Событийный цикл не вытесняет синхронный код. Цикл на 500 мс блокирует и рендер, и все колбэки. Разбивайте на части через
setTimeoutили выносите в Web Worker. - Порядок логов в тестах. Тесты, полагающиеся на «сначала таймер, потом промис», ломаются молча. Не завязывайтесь на порядок разных типов задач — синхронизируйте через
await. - Необработанный отказ промиса. Reject без
.catchвсплывает какunhandledRejection— в Node.js 15+ это по умолчанию завершает процесс.
Как проверить свою модель
Возьмите фрагмент ниже и предскажите вывод, прежде чем запускать. Если предсказали верно — модель у вас в голове рабочая.
console.log('sync 1');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
queueMicrotask(() => console.log('microtask'));
console.log('sync 2');
// sync 1, sync 2, promise 1, microtask, promise 2, timeoutОбратите внимание: promise 2 идёт после microtask, потому что второй .then ставится в очередь только когда исполнится первый — а queueMicrotask уже стоял в очереди к этому моменту.
Что делать дальше
Соберите сложный кусок асинхронной логики и прогоните его мысленно по правилу «синхронный код → все микрозадачи → одна макрозадача → снова все микрозадачи». Для отладки в Chromium используйте вкладку Performance — она показывает задачи и микрозадачи на таймлайне. В Node.js для замеров задержки цикла смотрите perf_hooks.monitorEventLoopDelay() — он покажет, насколько ваш синхронный код блокирует обработку событий. Следующий шаг для тех, кто отвечает за сервис: настройте мониторинг задержки событийного цикла в проде, растущая задержка — первый признак того, что где-то синхронный код или лавина микрозадач держат поток.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: MDN: The event loop, Node.js: The Node.js Event Loop
Частые вопросы
У меня старая Node.js, там порядок другой?
Почему setTimeout(fn, 0) не выполняется мгновенно?
В чём разница между queueMicrotask и Promise.resolve().then?
Мой код зависает, хотя нигде нет длинных циклов. В чём дело?
Как заставить тяжёлые вычисления не блокировать интерфейс?
Порядок setTimeout и setImmediate в Node.js предсказуем?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.