Перейти к содержимому

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(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.

Классическая задача, показывающая цикл в действии: обработчик 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.

Разбор по итерациям:

  1. Макрозадача — скрипт. Выполняется: start, постановка timeout 1 (в очередь макрозадач), реакция promise 1 (в очередь микрозадач), постановка timeout 2 (в очередь макрозадач), microtask (в микрозадачи), end. Стек пуст.
  2. Микрозадачи. promise 1 печатается; внутри неё ставится timeout внутри promise в очередь макрозадач; возврат undefined.then с promise 2 добавляется в конец микроочереди. Затем promise 2, затем microtask. Микроочередь пуста.
  3. Макрозадача timeout 1. Печатает, ставит promise внутри timeout в микроочередь. Стек пуст → немедленно опустошаем микроочередь: promise внутри timeout.
  4. Макрозадача timeout 2. Печатается.
  5. Макрозадача timeout внутри promise. Печатается.

Ключевой инсайт: каждый setTimeout — новая «комната» со своей уборкой. Задачи, поставленные внутри микрозадач (шаг 2), встают в общую очередь макрозадач и ждут своей итерации — они не «вклиниваются» раньше уже стоящих таймеров, но строго после них (FIFO в пределах одинаковой задержки).

Модель та же (микро > макро), но детали другие:

Аспект Браузер Node.js
Макрозадачи task queue (одна на тип в спеке HTML) Несколько очередей фаз: timers → pending → poll → check → close
setImmediate Нет Есть: выполнится в фазе check, сразу после poll
process.nextTick Нет Есть: «микромикрозадачи», выполняются раньше промисов
requestAnimationFrame Есть Нет (рендеринга нет; в терминале/SSR не нужен)
Фоновые таймеры Throttle вкладки Нет вкладок — работают полным ходом
MessageChannel Макрозадача Макрозадача (быстрее setTimeout, отсюда setImmediate-полифилы)
// Node.js: фазы event loop
setTimeout(() => 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 — их реакции идут в микроочередь напрямую, минуя межпоточные границы. Поэтому промисы «быстрее»: меньше переходов между потоками.

  1. Ожидать, что setTimeout(fn, 0) выполнится сразу. Он выполнится после текущей задачи И всех микрозадач. Если в микрозадачах бесконечная цепочка (например, then, который каждый раз ставит ещё then), таймер «никогда» не наступит — starvation макрозадач.
  2. Уверять, что таймер сработает точно через N мс. Гарантируется только «не раньше N мс». Задержки копятся при загруженном главном потоке.
  3. Тяжёлые вычисления в главном потоке без чанкования — UI-фризы. Лечение: разбить на куски через setTimeout/rAF, вынести в Worker.
  4. Делать layout-чтения внутри цикла после записей (layout thrashing). Батч: сначала все чтения, потом все записи, лучше — внутри rAF.
  5. Путать очереди Node.js и браузера: setImmediate/process.nextTick не существуют в браузере, requestAnimationFrame — в Node.js. Полифилы и кроссплатформенный код требуют аккуратности.
  6. Думать, что await в цикле параллелит запросы. Он последователен; параллельность — Promise.all (следующая глава).
  1. Порядок выполнения: синхронный код, микро-, макрозадачи? Синхронный код текущей задачи → все микрозадачи до конца очереди → (возможно рендеринг) → следующая макрозадача.
  2. Что попадает в микрозадачи, что в макрозадачи? Микро: промисные реакции, await-продолжения, queueMicrotask, MutationObserver. Макро: setTimeout/Interval, события DOM, <script>, postMessage/MessageChannel, fetch на уровне задач.
  3. Почему промисы «быстрее» setTimeout(0)? Реакции промисов идут в микроочередь и выполняются сразу после текущей задачи; таймер — новая макрозадача, ждёт своей итерации.
  4. Что такое starvation макрозадач и как возникает? Бесконечное пополнение микроочереди (цепочка then, каждый добавляет новый) блокирует цикл: микрозадачи всегда приоритетнее, макрозадачи не получают управления.
  5. Где в цикле выполняется requestAnimationFrame? После микрозадач, до style/layout/paint. Кадр рисуется после rAF-колбэков; в фоне rAF приостанавливается.
  6. Ключевые отличия Event Loop Node.js? Фазовая модель (timers/poll/check), setImmediate в фазе check, process.nextTick — отдельная «сверхмикро» очередь, нет rAF.
  1. Предскажи порядок и проверь в консоли:
    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);
  2. Напиши yieldToUI(): функцию, возвращающую промис, который резолвится в ближайшем свободном моменте главного потока ПОСЛЕ рендеринга (подсказка: requestAnimationFrame + двойной setTimeout или MessageChannel для теста — объясни разницу).
  3. Реализуй scheduleChunk(items, processItem): обрабатывает массив кусками по ~8 мс, уступая цикл между кусками, чтобы UI оставался отзывчивым. Критерий: нет long tasks > 50 мс, порядок элементов сохранён.
  4. Напиши демо starvation: цепочка промисов, которая каждый раз добавляет новый then, и таймер 0 мс. Покажи, что таймер откладывается, и исправь код (ограничить длину цепочки, выносить продолжение в setTimeout).
  5. Порт задачи из Node.js: код использует setImmediate и process.nextTick. Перепиши на браузерный Event Loop, сохранив порядок (nextTick → promise → immediate ≈ queueMicrotask → promise → setTimeout(0)), и объясни, где семантика теряется.