Событийный цикл в JavaScript: рабочие приёмы из практики
Почему setTimeout(fn, 0) выполняется позже, чем Promise.then, и как это использовать: разбор порядка задач в событийном цикле на реальных приёмах для браузера и Node.js.

Классическая ловушка: вы пишете 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. Разница между b и c — не баг, а следствие того, что промисы и таймеры попадают в разные очереди событийного цикла. Ниже — приёмы, которые работают в браузере и в Node.js 20+, и цена каждого.
Речь про порядок выполнения асинхронного кода на одном потоке. Внутренности V8 и реализацию libuv не разбираем — только наблюдаемое поведение, на которое можно опереться в коде.
Две очереди, а не одна
Движок выполняет синхронный код до конца, а всё асинхронное складывает в две разные очереди:
- Микрозадачи (microtasks) — колбэки
Promise.then/catch/finally,queueMicrotask,awaitпосле точки возобновления, а такжеMutationObserverв браузере. - Макрозадачи (tasks) —
setTimeout,setInterval, обработчики событий DOM,setImmediateв Node.js, сетевые колбэки.
Правило, которое объясняет пример из начала: после каждой макрозадачи движок опустошает всю очередь микрозадач целиком, и только потом берёт следующую макрозадачу. Синхронный код — это тоже фактически первая «задача»: он отрабатывает, затем сливаются все микрозадачи, затем первый setTimeout.
Микрозадача не даёт браузеру передохнуть. Пока очередь микрозадач не пуста, рендеринг, обработка кликов и таймеры ждут. Бесконечно порождающая себя микрозадача повесит вкладку намертво — без единого while(true).Почему await меняет порядок
Строка после await — это микрозадача, а не продолжение синхронного потока. Функция ставится на паузу и возобновляется как колбэк then:
// поведение одинаково в браузере и Node.js 20+
async function run() {
console.log('1'); // синхронно
await null; // точка возобновления
console.log('2'); // микрозадача
}
console.log('start');
run();
console.log('end');
// Порядок вывода: start, 1, end, 2Строки до первого await выполняются синхронно прямо в момент вызова. Всё, что после — откладывается. Это ломает интуицию тех, кто считает, что async-функция целиком асинхронна.
Приём 1: разбить тяжёлый цикл, не заморозив интерфейс
Вы обрабатываете массив на 200 000 элементов в браузере и вкладка виснет: пока цикл крутится, событийный цикл не доходит до перерисовки и до обработки кликов. Нужно отдать управление движку между порциями.
Здесь важно выбрать правильный вид паузы. await Promise.resolve() — это микрозадача, она НЕ разблокирует рендеринг, потому что микроочередь сливается до отрисовки. Нужна макрозадача:
// браузер, разбивка тяжёлой обработки на кадры
async function processInChunks(items, chunkSize = 500) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
for (const item of chunk) {
heavyWork(item);
}
// отдаём управление: пусть браузер отрисует кадр
await new Promise((resolve) => setTimeout(resolve, 0));
}
}Проверка: во время обработки интерфейс отвечает на клики, а не замирает. Цена — общее время растёт: каждый setTimeout(0) в браузере имеет минимальную задержку, а вложенные таймеры после пятого уровня клэмпятся до 4 мс по спецификации HTML. Для по-настоящему тяжёлых расчётов правильный ответ — Web Worker, а не нарезка на таймерах.
Приём 2: отложить работу на «после текущего кадра»
Вам нужно выполнить код после того, как весь синхронный стек и все накопившиеся промисы отработали, но до следующего таймера. Это ниша queueMicrotask:
// собрать несколько синхронных изменений и обработать их разом
let pending = false;
const changes = [];
function scheduleFlush(change) {
changes.push(change);
if (!pending) {
pending = true;
queueMicrotask(() => {
flush(changes.splice(0)); // обработать всё накопленное разом
pending = false;
});
}
}Так работает батчинг обновлений в реактивных библиотеках: десять синхронных вызовов scheduleFlush дадут один flush. Цена queueMicrotask против setTimeout(0) — микрозадача выполнится раньше и не пропустит браузер к рендерингу. Если вам нужен именно кадр перед обработкой, берите requestAnimationFrame, а не микрозадачу.
Приём 3: не проглотить ошибку в асинхронном колбэке
Ошибка, брошенная внутри колбэка setTimeout, не ловится внешним try/catch — к моменту выполнения колбэка синхронный стек с try уже раскручен:
// НЕ сработает: колбэк выполняется в отдельной задаче
try {
setTimeout(() => {
throw new Error('упало');
}, 0);
} catch (e) {
console.log('поймали', e); // сюда не попадём
}Обрабатывать такую ошибку нужно внутри самого колбэка либо на глобальном уровне: window.onerror / событие error в браузере, process.on('uncaughtException') в Node.js. С промисами отдельная беда — необработанный reject тихо повисает; ловите событие unhandledrejection в браузере и process.on('unhandledRejection') в Node.js.
Node.js: свои фазы и setImmediate
В Node.js событийный цикл разбит на фазы, и между ними есть два дополнительных механизма, которых нет в браузере:
| Механизм | Когда выполняется | Типичное применение |
|---|---|---|
process.nextTick | перед любыми микрозадачами, сразу после текущей операции | отложить на «прямо сейчас, но не синхронно» |
queueMicrotask / Promise | после nextTick, до перехода к следующей фазе | обычная асинхронная логика |
setImmediate | в фазе check, после I/O-колбэков текущей итерации | выполнить «после текущего цикла I/O» |
setTimeout(fn, 0) | в фазе timers, с минимальной задержкой около 1 мс | отложенный запуск с задержкой |
Ловушка: process.nextTick имеет приоритет выше промисов. Рекурсивный nextTick заблокирует переход к фазе I/O так же, как микрозадача блокирует рендеринг в браузере. Внутри I/O-колбэка порядок setImmediate и setTimeout(0) детерминирован — setImmediate сработает раньше, потому что фаза check идёт до следующего захода в timers. Вне I/O-контекста их порядок не гарантирован.
Что ломается на практике
- Голодание рендеринга. Цепочка промисов, которая порождает следующий промис в
then, держит очередь микрозадач непустой и не пускает браузер к отрисовке. Симптом: анимация замирает, хотя процессор не загружен на 100%. - Ожидание, что
awaitв цикле идёт параллельно.for (const x of arr) { await fetch(x) }выполняется строго последовательно. Для параллельности —Promise.all(arr.map(fetch)), но тогда контролируйте число одновременных запросов. - Таймеры на фоновой вкладке. Браузер троттлит
setTimeoutв неактивной вкладке до 1000 мс и реже. Приём с нарезкой на таймерах в фоне почти остановится. - Разный порядок в браузере и Node.js. Код, где вы полагаетесь на порядок
setTimeoutпротивsetImmediate, поведёт себя по-разному. Не завязывайте логику на порядок между макрозадачами.
Что делать дальше
Соберите мысленную модель на трёх опорах: синхронный код до конца, затем вся очередь микрозадач, затем одна макрозадача — и по кругу. Проверьте себя на спорных сниппетах, прогнав их в консоли браузера и через node script.js раздельно: расхождения покажут, где вы неявно полагаетесь на реализацию. В проде смотрите на Long Tasks в Performance-панели DevTools: задачи дольше 50 мс — кандидаты на разбивку или вынос в Web Worker. Для тяжёлых вычислений с самого начала закладывайте Worker, а событийный цикл оставьте для оркестрации, а не для счёта.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: MDN — In depth: Microtasks and the JavaScript runtime environment, Node.js — The Node.js Event Loop, Timers, and process.nextTick()
Частые вопросы
Почему Promise.then выполняется раньше, чем setTimeout(fn, 0)?
А если у меня старая версия Node.js, порядок будет другим?
Не сработало: интерфейс всё равно виснет при нарезке на setTimeout. В чём дело?
queueMicrotask или setTimeout — что выбрать для отложенного вызова?
Почему try/catch не ловит ошибку из setTimeout?
Как понять, что микрозадача заблокировала рендеринг в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.