Event Loop в браузере
Почему setTimeout(fn, 0) выполняется ПОСЛЕ console.log на следующей строке? Почему Promise.then обгоняет таймер? Почему тяжёлый цикл «замерзает» кнопку, хотя обработчик уже повешен? Ответ на все три вопроса — один механизм: Event Loop. JS однопоточен, но браузер — нет. Асинхронные операции (таймеры, сеть, события) выполняет окружение, а JS получает результаты через очереди задач.
В краткой версии мы обозначили правило «микрозадачи раньше макрозадач». Здесь — полная модель: из чего состоит итерация цикла, где в ней живёт requestAnimationFrame, что такое рендеринг и почему код в Node.js ведёт себя похоже, но не одинаково.
Модель: из чего состоит итерация цикла
Заголовок раздела «Модель: из чего состоит итерация цикла»Браузерный Event Loop — бесконечный цикл, каждая итерация которого выглядит так:
┌─────────────────────────────────────────────┐│ 1. Выполнить ОДНУ задачу из очереди ││ макрозадач (script, setTimeout, событие) ││ 2. Выполнить ВСЕ микрозадачи (до конца ││ очереди): Promise.then/catch/finally, ││ await, queueMicrotask, MutationObserver ││ 3. (при необходимости) Рендеринг: ││ requestAnimationFrame → style/layout → ││ paint → composite ││ 4. Следующая итерация │└─────────────────────────────────────────────┘Ключевые структуры:
- Call Stack — синхронный код текущей задачи. Пока стек не пуст — Event Loop ждёт.
- Очередь макрозадач (task queue) —
setTimeout/setInterval, события DOM (click,message),fetch-колбэки на уровне задач,<script>. - Очередь микрозадач (microtask queue) — промисные реакции
.then/.catch/.finally, продолжения послеawait,queueMicrotask,MutationObserver.
Главное правило: после каждой макрозадачи Event Loop опустошает микрозадачи полностью — даже если во время их выполнения появились новые. Только потом идёт рендеринг и следующая макрозадача.
console.log('1: синхронно (макрозадача <script>)');
setTimeout(() => console.log('2: макрозадача (таймер)'), 0);
Promise.resolve() .then(() => console.log('3: микрозадача 1')) .then(() => console.log('4: микрозадача 2'));
queueMicrotask(() => console.log('5: микрозадача 3'));
console.log('6: синхронно, конец скрипта');
// Порядок: 1 → 6 → 3 → 4 → 5 → 2// Скрипт выполнился, затем ВСЕ микрозадачи, затем таймер.Микрозадачи: почему промисы «догоняют»
Заголовок раздела «Микрозадачи: почему промисы «догоняют»»Продолжение async-функции после await — это микрозадача. Поэтому код после await выполняется раньше любого setTimeout(..., 0):
async function demo() { console.log('A'); await Promise.resolve(); // функция приостановилась, продолжение — в микрозадачи console.log('B'); // микрозадача}
demo();console.log('C');setTimeout(() => console.log('D'), 0);
// A → C → B → D// B микрозадача обгоняет D макрозадачуМикрозадачи — механизм «немедленно после текущего кода, но до рендеринга». Именно поэтому await в цикле не блокирует рендеринг полностью (в отличие от синхронного цикла), но и не даёт странице «дышать» между итерациями — об этом в главе про async/await.
Опустошение микроочереди на практике
Заголовок раздела «Опустошение микроочереди на практике»Правило «все микрозадачи до конца» имеет неприятное следствие: если каждая микрозадача ставит новую, макрозадачи и рендеринг откладываются бесконечно. Классический триггер — длинные цепочки промисов, построенные по одному шаблону:
// ПЛОХО: 10 000 шагов промисов подряд — страница не отрисуется и не ответит// на события, пока цепочка не закончится (starvation макрозадач)function step(n) { return Promise.resolve().then(() => { if (n === 0) return 'готово'; return step(n - 1); // каждый then ставит следующую микрозадачу });}step(10_000);
// ХОРОШО: периодически «дышим» — выносим продолжение в макрозадачуfunction stepChunked(n) { if (n === 0) return Promise.resolve('готово'); return new Promise((resolve) => setTimeout(resolve, 0)) // макрозадача-разделитель .then(() => stepChunked(n - 1));}Исторический нюанс: до появления промисов микрозадачами были только MutationObserver-колбэки — паттерм «вызови свой код после текущего, но до рендеринга» реализовывался через подмену DOM-атрибута:
// Легаси-паттерн: «микрозадача до промисов»const observer = new MutationObserver(() => console.log('микрозадача сработала'));const div = document.createElement('div');observer.observe(div, { attributes: true });div.setAttribute('data-x', '1'); // ставит микрозадачу — выведется до таймераsetTimeout(() => console.log('макрозадача'), 0);Сегодня это нужно только для чтения старого кода — queueMicrotask и промисы решают ту же задачу явно.
Макрозадачи: таймеры и события
Заголовок раздела «Макрозадачи: таймеры и события»setTimeout(fn, 0) — не «немедленно», а «в ближайшую свободную итерацию цикла после микрозадач». Минимальная задержка в браузере ~1–4 мс и больше, если вкладка фоновая или главный поток занят:
setTimeout(() => console.log('таймер 0 мс'), 0);for (let i = 0; i < 1e9; i++) { /* тяжёлый цикл на 2 секунды */ }// Таймер сработает только после окончания цикла:// таймеры меряют время с момента постановки, но исполнение — только когда стек свободен.События DOM (click, input, message от Web Worker) тоже макрозадачи: обработчик выполнится целиком, потом микрозадачи, потом следующее событие. Два быстрых клика — две отдельные задачи, не переплетаются.
requestAnimationFrame и рендеринг
Заголовок раздела «requestAnimationFrame и рендеринг»requestAnimationFrame(cb) — просьба «вызови cb перед СЛЕДУЮЩИМ repaint». Порядок в итерации цикла: макрозадача → микрозадачи → rAF-колбэки → style/layout (reflow) → paint → composite. Поэтому чтение layout-свойств (offsetWidth и др.) внутри rAF-без надобности — лишний reflow не нужен, но и внутри микрозадач layout-чтение после записи принудительно делает синхронный reflow (layout thrashing).
function animate() { // Здесь: чтение layout (getBoundingClientRect), запись transform, // логика анимации. Всё до repaint. movePlayer(); requestAnimationFrame(animate); // следующий кадр}requestAnimationFrame(animate);Важно: rAF приостанавливается в фоновых вкладках (браузер экономит ресурсы), таймеры — нет (с throttle). Для игр/анимаций в фоне учитывай document.visibilitychange.
Пример: throttle через requestAnimationFrame
Заголовок раздела «Пример: throttle через requestAnimationFrame»Классическая задача, показывающая цикл в действии: обработчик scroll/mousemove/resize вызывается десятки раз в секунду, а реагировать хочется не чаще кадра. Решение — throttle через rAF: не чаще одного запуска на кадр, синхронизация с рендерингом:
function rafThrottle(fn) { let queued = false; let lastArgs = null;
return function throttled(...args) { lastArgs = args; if (queued) return; // на кадр уже запланирован запуск
queued = true; requestAnimationFrame(() => { queued = false; fn.apply(this, lastArgs); // this и свежие аргументы сохраняем }); };}
// Использование: тяжёлый пересчёт позиции тултипаconst updateTooltip = rafThrottle((event) => { tooltip.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;});document.addEventListener('mousemove', updateTooltip);Почему именно rAF, а не setTimeout(16): rAF привязан к реальному моменту отрисовки. Если вкладка скрыта, колбэки не накапливаются; при 120 Гц мониторе callback получит актуальное время кадра через аргумент timestamp. setTimeout же «угадывает» 16 мс и в фоне продолжал бы работать.
Обратный паттерн — debounce через Event Loop: отложить выполнение до паузы в событиях (поисковая строка). Там уместнее setTimeout, потому что важна тишина, а не синхронизация с кадром.
Приоритеты задач: scheduler.postTask и фоновая работа
Заголовок раздела «Приоритеты задач: scheduler.postTask и фоновая работа»Современный браузер даёт инструменты управления приоритетами задач без ручного «таймерного хака». API scheduler.postTask (Chrome 94+) позволяет ставить задачи в очередь с приоритетом: user-blocking, user-visible (по умолчанию), background:
// Критичный фидбек — максимальный приоритетscheduler.postTask(() => closeModal(), { priority: 'user-blocking' });
// Разбор ответа сервера — обычный приоритетscheduler.postTask(() => renderItems(data), { priority: 'user-visible' });
// Аналитика и телеметрия — не мешаем интерфейсуscheduler.postTask(() => sendMetrics(payload), { priority: 'background' });Это решает классическую проблему: тяжёлый некритичный код (логирование, префетч, построение индексов) мешает отзывчивости, хотя мог бы выполниться в любой паузе. С postTask браузер сам подберёт момент для фоновых задач, не блокируя ввод и рендеринг.
Для обратной совместимости паттерн «yield через макрозадачу» реализуют через MessageChannel (быстрее setTimeout(0), который в браузерах клампится до ~1-4 мс после пяти вложенных вызовов):
const channel = new MessageChannel();function yieldTask() { return new Promise((resolve) => { channel.port1.onmessage = resolve; channel.port2.postMessage(null); // макрозадача без задержки таймера });}
// Использование внутри длинного цикла обработкиfor (const chunk of chunks) { processChunk(chunk); await yieldTask(); // уступить цикл, если появились задачи повыше}Разница с микрозадачей (queueMicrotask) принципиальна: yieldTask уступает и рендерингу, и другим макрозадачам, а queueMicrotask выполнится раньше них всех. Выбирай осознанно: «после текущего кода, но до кадра» — микрозадача; «в следующий свободный момент» — MessageChannel/scheduler.
Большая задача: предскажи порядок вывода
Заголовок раздела «Большая задача: предскажи порядок вывода»Не запуская, определи порядок. Разбор ниже — попробуй сам.
console.log('start');
setTimeout(() => { console.log('timeout 1'); Promise.resolve().then(() => console.log('promise внутри timeout'));}, 0);
Promise.resolve().then(() => { console.log('promise 1'); setTimeout(() => console.log('timeout внутри promise'), 0);}).then(() => console.log('promise 2'));
setTimeout(() => console.log('timeout 2'), 0);
queueMicrotask(() => console.log('microtask'));
console.log('end');Правильный ответ: start → end → promise 1 → promise 2 → microtask → timeout 1 → promise внутри timeout → timeout 2 → timeout внутри promise.
Разбор по итерациям:
- Макрозадача — скрипт. Выполняется:
start, постановкаtimeout 1(в очередь макрозадач), реакцияpromise 1(в очередь микрозадач), постановкаtimeout 2(в очередь макрозадач),microtask(в микрозадачи),end. Стек пуст. - Микрозадачи.
promise 1печатается; внутри неё ставитсяtimeout внутри promiseв очередь макрозадач; возвратundefined→.thenсpromise 2добавляется в конец микроочереди. Затемpromise 2, затемmicrotask. Микроочередь пуста. - Макрозадача
timeout 1. Печатает, ставитpromise внутри timeoutв микроочередь. Стек пуст → немедленно опустошаем микроочередь:promise внутри timeout. - Макрозадача
timeout 2. Печатается. - Макрозадача
timeout внутри promise. Печатается.
Ключевой инсайт: каждый setTimeout — новая «комната» со своей уборкой. Задачи, поставленные внутри микрозадач (шаг 2), встают в общую очередь макрозадач и ждут своей итерации — они не «вклиниваются» раньше уже стоящих таймеров, но строго после них (FIFO в пределах одинаковой задержки).
Отличия от Node.js
Заголовок раздела «Отличия от Node.js»Модель та же (микро > макро), но детали другие:
| Аспект | Браузер | Node.js |
|---|---|---|
| Макрозадачи | task queue (одна на тип в спеке HTML) | Несколько очередей фаз: timers → pending → poll → check → close |
setImmediate |
Нет | Есть: выполнится в фазе check, сразу после poll |
process.nextTick |
Нет | Есть: «микромикрозадачи», выполняются раньше промисов |
requestAnimationFrame |
Есть | Нет (рендеринга нет; в терминале/SSR не нужен) |
| Фоновые таймеры | Throttle вкладки | Нет вкладок — работают полным ходом |
MessageChannel |
Макрозадача | Макрозадача (быстрее setTimeout, отсюда setImmediate-полифилы) |
// Node.js: фазы event loopsetTimeout(() => console.log('timer'), 0);setImmediate(() => console.log('immediate'));// В основном модуле: порядок НЕ гарантирован (зависит от времени старта),// внутри I/O-колбэка: setImmediate ВСЕГДА раньше setTimeout(0).
// nextTick обгоняет промисы:process.nextTick(() => console.log('nextTick'));Promise.resolve().then(() => console.log('promise'));// nextTick → promiseДля фронтенд-разработчика главное практическое следствие: код, написанный под браузерный Event Loop, переносится в Node.js почти всегда корректно (если не полагается на rAF и порядок таймеров с setImmediate). Обратное — тоже, минус setImmediate/nextTick.
Как это работает под капотом
Заголовок раздела «Как это работает под капотом»Браузер держит главный поток с JS-движком и другие потоки: сетевой стек, композитор, raster-воркеры. Завершившийся fetch не «прыгает» в JS — он кладёт задачу в очередь через posting task механизм (HTML-спека: «queue a task»). Промисы же резолвятся синхронно внутри JS — их реакции идут в микроочередь напрямую, минуя межпоточные границы. Поэтому промисы «быстрее»: меньше переходов между потоками.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Ожидать, что
setTimeout(fn, 0)выполнится сразу. Он выполнится после текущей задачи И всех микрозадач. Если в микрозадачах бесконечная цепочка (например,then, который каждый раз ставит ещёthen), таймер «никогда» не наступит — starvation макрозадач. - Уверять, что таймер сработает точно через N мс. Гарантируется только «не раньше N мс». Задержки копятся при загруженном главном потоке.
- Тяжёлые вычисления в главном потоке без чанкования — UI-фризы. Лечение: разбить на куски через
setTimeout/rAF, вынести в Worker. - Делать layout-чтения внутри цикла после записей (layout thrashing). Батч: сначала все чтения, потом все записи, лучше — внутри rAF.
- Путать очереди Node.js и браузера:
setImmediate/process.nextTickне существуют в браузере,requestAnimationFrame— в Node.js. Полифилы и кроссплатформенный код требуют аккуратности. - Думать, что
awaitв цикле параллелит запросы. Он последователен; параллельность —Promise.all(следующая глава).
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Порядок выполнения: синхронный код, микро-, макрозадачи? Синхронный код текущей задачи → все микрозадачи до конца очереди → (возможно рендеринг) → следующая макрозадача.
- Что попадает в микрозадачи, что в макрозадачи?
Микро: промисные реакции,
await-продолжения,queueMicrotask,MutationObserver. Макро:setTimeout/Interval, события DOM,<script>,postMessage/MessageChannel,fetchна уровне задач. - Почему промисы «быстрее» setTimeout(0)? Реакции промисов идут в микроочередь и выполняются сразу после текущей задачи; таймер — новая макрозадача, ждёт своей итерации.
- Что такое starvation макрозадач и как возникает?
Бесконечное пополнение микроочереди (цепочка
then, каждый добавляет новый) блокирует цикл: микрозадачи всегда приоритетнее, макрозадачи не получают управления. - Где в цикле выполняется requestAnimationFrame? После микрозадач, до style/layout/paint. Кадр рисуется после rAF-колбэков; в фоне rAF приостанавливается.
- Ключевые отличия Event Loop Node.js?
Фазовая модель (timers/poll/check),
setImmediateв фазе check,process.nextTick— отдельная «сверхмикро» очередь, нет rAF.
Практика
Заголовок раздела «Практика»- Предскажи порядок и проверь в консоли:
console.log(1);setTimeout(() => console.log(2));Promise.resolve().then(() => console.log(3));setTimeout(() => {console.log(4);Promise.resolve().then(() => console.log(5));});console.log(6);
- Напиши
yieldToUI(): функцию, возвращающую промис, который резолвится в ближайшем свободном моменте главного потока ПОСЛЕ рендеринга (подсказка:requestAnimationFrame+ двойнойsetTimeoutилиMessageChannelдля теста — объясни разницу). - Реализуй
scheduleChunk(items, processItem): обрабатывает массив кусками по ~8 мс, уступая цикл между кусками, чтобы UI оставался отзывчивым. Критерий: нет long tasks > 50 мс, порядок элементов сохранён. - Напиши демо starvation: цепочка промисов, которая каждый раз добавляет новый
then, и таймер 0 мс. Покажи, что таймер откладывается, и исправь код (ограничить длину цепочки, выносить продолжение вsetTimeout). - Порт задачи из Node.js: код использует
setImmediateиprocess.nextTick. Перепиши на браузерный Event Loop, сохранив порядок (nextTick → promise → immediate ≈ queueMicrotask → promise → setTimeout(0)), и объясни, где семантика теряется.