Событийный цикл в JavaScript: ошибки, которые ломают порядок
Почему setTimeout с нулём срабатывает позже промиса, зачем await внутри forEach не ждёт и как один тяжёлый цикл замораживает весь интерфейс. Разбираем типичные ошибки с event loop.

Вы ставите setTimeout(fn, 0), ожидая, что колбэк выполнится сразу после текущей строки. А он выполняется после промиса, который вы создали позже. Или вы оборачиваете асинхронный запрос в array.forEach(async ...) и получаете гонку вместо последовательной обработки. Оба бага — не про синтаксис, а про то, как движок раскладывает вашу задачу по очередям. Разберём четыре ошибки, которые чаще всего ловят на код-ревью и в проде, и правило, по которому их можно предсказать.
Материал ориентирован на среду с современным движком: V8 (Node.js 18+ и Chrome), поведение микрозадач стабилизировалось по спецификации ещё в 2019 году, так что для актуальных браузеров и Node всё описанное верно.
Как крутится цикл — минимум, без которого дальше нельзя
Движок JavaScript однопоточный: в каждый момент выполняется ровно один кусок кода — синхронный стек вызовов. Когда стек пуст, движок берёт следующую работу из очередей. Очередей две, и приоритет у них разный.
- Очередь микрозадач (microtask queue) — сюда попадают колбэки промисов (
.then,.catch,.finally), тело послеawaitиqueueMicrotask. Движок опустошает её полностью перед тем, как перейти к следующему пункту. - Очередь макрозадач (task queue, она же callback queue) —
setTimeout,setInterval, обработчики событий DOM, сетевые колбэки. За один оборот цикла берётся одна макрозадача, после неё снова опустошается вся очередь микрозадач.
Микрозадача vs макрозадача. Микрозадачи — короткие продолжения уже начатой работы (реакция на промис). Макрозадачи — новые единицы работы, запланированные извне (таймер, событие). Ключевое: между двумя макрозадачами движок всегда прогоняет все накопившиеся микрозадачи.
Ошибка 1: setTimeout(fn, 0) как «выполни сразу после»
Классика. Разработчик хочет отложить код «на конец текущей операции» и пишет нулевой таймаут. А потом удивляется порядку логов.
console.log('1: синхронно');
setTimeout(() => console.log('2: таймаут 0'), 0);
Promise.resolve().then(() => console.log('3: промис'));
console.log('4: синхронно');
// Порядок вывода:
// 1: синхронно
// 4: синхронно
// 3: промис
// 2: таймаут 0Сначала выполняется весь синхронный код (1 и 4). Затем движок опустошает очередь микрозадач — печатается промис (3). И только потом берётся макрозадача таймера (2). Даже с нулём таймаут проиграет любому промису, который встал в очередь раньше.
Как правильно. Если нужно отложить работу до опустошения текущего синхронного стека, но перед следующим рендером и таймерами — используйте queueMicrotask. Если нужно именно уступить движку, чтобы он обработал ввод и отрисовку, — тогда setTimeout уместен, но не ждите от него мгновенности.
Ошибка 2: await внутри forEach ничего не ждёт
Самая коварная, потому что код выглядит правильным и даже иногда работает — пока данных мало.
// НЕ РАБОТАЕТ как последовательная обработка
async function saveAll(items) {
items.forEach(async (item) => {
await saveToDb(item); // await ждёт внутри колбэка, но forEach его не ждёт
});
console.log('всё сохранено'); // печатается ДО завершения запросов
}forEach не знает про промисы. Он синхронно вызывает колбэк для каждого элемента и идёт дальше, не дожидаясь возвращённого промиса. В итоге все saveToDb запускаются почти одновременно, а строка «всё сохранено» печатается раньше, чем завершится хотя бы один запрос. При большом массиве это кладёт пул соединений к базе или упирается в лимит запросов внешнего API.
Как правильно — два варианта под разные задачи:
// Последовательно: каждый следующий ждёт предыдущего
async function saveSequential(items) {
for (const item of items) {
await saveToDb(item);
}
console.log('всё сохранено');
}
// Параллельно, но с контролем завершения
async function saveParallel(items) {
await Promise.all(items.map((item) => saveToDb(item)));
console.log('всё сохранено');
}Если код должен дождаться асинхронных операций в цикле —forEachне ваш инструмент. Либоfor...ofсawaitдля последовательности, либоPromise.allсmapдля параллельности. Третьего безопасного варианта нет.
Ошибка 3: тяжёлый синхронный цикл замораживает всё
Однопоточность означает: пока крутится ваш for на миллион итераций, движок не берёт ни одной задачи из очередей. В браузере это фриз интерфейса — кнопки не нажимаются, анимации стоят. В Node.js это заблокированный event loop: сервер перестаёт принимать новые соединения.
// Блокирует поток на всё время выполнения
function processHeavy(bigArray) {
let result = 0;
for (const n of bigArray) {
result += expensiveCalc(n); // синхронно, десятки мс на элемент
}
return result;
}Проблема не в самом цикле, а в том, что он монолитный. Пока он не завершится, микро- и макрозадачи копятся, но не выполняются.
Как правильно. Разбейте работу на порции и уступайте цикл между ними. В браузере для не-срочной работы подходит requestIdleCallback (поддержка есть в Chrome и Firefox, в Safari — нет, проверяйте наличие). Универсальный приём — резать на чанки через макрозадачу:
// Обработка порциями, event loop дышит между чанками
async function processInChunks(bigArray, chunkSize = 500) {
let result = 0;
for (let i = 0; i < bigArray.length; i += chunkSize) {
const chunk = bigArray.slice(i, i + chunkSize);
for (const n of chunk) {
result += expensiveCalc(n);
}
// уступаем движку: он обработает ввод, рендер, другие задачи
await new Promise((resolve) => setTimeout(resolve, 0));
}
return result;
}Для по-настоящему тяжёлых вычислений выносите их из основного потока: Worker в браузере, worker_threads в Node.js. Но это уже отдельная тема — здесь мы говорим только про поведение внутри одного потока.
Ошибка 4: рекурсивные микрозадачи блокируют рендер
Микрозадачи опустошаются полностью перед возвратом управления движку. Если микрозадача планирует новую микрозадачу, а та — ещё одну, движок застревает в этой очереди и не доходит до отрисовки.
// Бесконечно откладывает рендер и макрозадачи
function loop() {
queueMicrotask(loop);
}
loop();С таймером (setTimeout(loop, 0)) браузер успевал бы отрисовывать кадры между итерациями, потому что рендер — это отдельная фаза после макрозадачи. С микрозадачей — нет: очередь микрозадач должна опустеть, а она не пустеет. Интерфейс замирает намертво.
Как правильно. Рекурсивную или циклическую работу планируйте через макрозадачи (setTimeout) или requestAnimationFrame для анимаций. Микрозадачи — только для короткого продолжения текущей операции, не для организации циклов.
Сравнение способов отложить работу
| Способ | Куда попадает | Когда выполнится | Для чего |
|---|---|---|---|
queueMicrotask | Очередь микрозадач | После текущего стека, до рендера и таймеров | Короткое продолжение операции |
Promise.resolve().then | Очередь микрозадач | Так же, как queueMicrotask | Цепочки промисов |
setTimeout(fn, 0) | Очередь макрозадач | После микрозадач и возможного рендера | Уступить движку, разбить работу |
requestAnimationFrame | Перед следующей отрисовкой | ~перед кадром (браузер) | Анимации, визуальные обновления |
Правило, по которому предсказывается порядок
Держите в голове цикл из трёх шагов, и большинство «странных» багов перестанут быть странными:
- Выполнить весь синхронный код текущей задачи до конца.
- Опустошить всю очередь микрозадач (включая те, что добавились по ходу).
- Взять одну макрозадачу, выполнить, вернуться к шагу 2.
Если ответ на вопрос «что выполнится раньше» неочевиден — разложите код по этим трём шагам вручную. Микрозадача всегда обгонит макрозадачу, вставшую в очередь раньше или одновременно.
Что делать дальше
Проверьте свой код на две вещи: нет ли forEach с async-колбэком там, где важен порядок или момент завершения, и нет ли длинных синхронных циклов на пользовательском вводе или в обработчиках запросов. В Node.js мерьте задержку event loop — например, встроенным perf_hooks.monitorEventLoopDelay: если p99 задержки уходит в десятки миллисекунд под нагрузкой, значит поток чем-то заблокирован. Дальше стоит посмотреть в сторону выноса тяжёлых вычислений в worker_threads и разбиения долгих операций на чанки — но это уже про масштабирование, а не про порядок выполнения.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: HTML Living Standard — Event loops, MDN — The event loop
Частые вопросы
Порядок из статьи одинаков в браузере и в Node.js?
А если у меня старый браузер или Node ниже 12?
Почему мой setTimeout(fn, 0) срабатывает не через 0 мс, а через 4 и больше?
Promise.all запускает запросы параллельно — не положит ли это внешний API?
Не сработало — код в for...of с await всё равно идёт параллельно. Почему?
Как замерить, что event loop заблокирован в Node.js?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.