Event Loop в браузере: что реально спрашивают на мидл- и сеньор-интервью

10 сентября 2026 г. · javascript, browser, interview, architecture

JS в браузере выполняется в один поток. Один стек вызовов, одна инструкция за раз — и этот же поток красит интерфейс, обрабатывает клики и скролл. Без механизма, который умеет ставить код на паузу и возвращаться к нему позже, любой сетевой запрос замораживал бы страницу целиком до своего завершения.

Event loop — это то, что делает асинхронность возможной без второго потока. На собеседованиях часто валят на том, что event loop — не часть JavaScript. Это часть хост-окружения: в браузере — HTML/WHATWG, не ECMAScript. В Node.js похожая модель, но со своими деталями (process.nextTick, фазы libuv). Про это будет отдельная часть.

«Микротаски выполняются раньше макротасков» — такого ответа достаточно, чтобы закрыть базовый вопрос. Дальше обычно копают глубже: где в этой модели место рендерингу кадра, чем requestAnimationFrame отличается от setTimeout(fn, 0), и почему SharedArrayBuffer в браузере — не всегда доступный инструмент.

Спецификация языка vs хост-окружение

Здесь проходит жирная черта между мидлом и сеньором. Считать event loop частью самого JS — ошибка.

Ни event loop, ни макрозадачи не описаны в спецификации ECMAScript. Их определяет браузер — конкретно спецификация HTML (раздел «Event loops» в WHATWG HTML Standard). setTimeout, обработка кликов и скролла, рендеринг — всё это Web API. Движок (V8, SpiderMonkey и подобные) вообще не в курсе, что такое «event loop» как формальное понятие. Эту концепцию реализует и крутит браузер, а движок просто выполняет JS, когда его просят.

А вот микрозадачи — другая история. Promise — часть самого языка, и спецификации ECMA-262 пришлось формально описать, когда исполняются его коллбэки — отсюда абстрактное понятие «Jobs»/«PromiseJobs» прямо в тексте спеки. Микрозадачи в этом смысле «более родные» для языка, чем макрозадачи.

Как отвечать коротко: event loop и макрозадачи — часть хост-окружения (браузера), не спецификации JS. А микрозадачи через Promise описаны в самой спецификации ECMAScript, потому что Promise — часть языка.

Task queue vs microtask queue — и куда девается рендеринг

В терминах спецификации HTML очередь макрозадач называется task queue. На самом деле их несколько — задачи из разных источников могут группироваться в разные очереди, но для интервью достаточно думать об этом как об одной модели. Очередь микрозадач — microtask queue, общая для всех источников.

Источники задач:

  • Task queue (макрозадачи): setTimeout/setInterval, обработка событий (click, input, scroll), парсинг HTML, сетевые колбэки.
  • Microtask queue (микрозадачи): Promise.then/.catch/.finally, queueMicrotask, продолжение после await (всегда через микрозадачу, даже если справа не промис), колбэки MutationObserver (микрозадачный источник, которого нет в Node.js вообще).

Модель работы та же, что и в любом хост-окружении: одна задача из task queue за итерацию, затем — когда стек опустел — полностью выгребается microtask queue, включая те задачи, что добавились уже по ходу её собственной обработки. И только после этого браузер решает следующий шаг.

А следующий шаг — не всегда следующая задача из task queue. Между итерациями цикла браузер может выполнить update the rendering — шаг, которого нет ни в спецификации ECMAScript, ни в Node.js: пересчитать стили, layout, отрисовать кадр. Как правило, это происходит раз в кадр (синхронизировано с частотой обновления экрана), и только если что-то в DOM/CSSOM реально изменилось — вхолостую браузер кадр не красит.

requestAnimationFrame(fn) ставит колбэк не в task queue и не в microtask queue, а именно перед шагом рендеринга — гарантированно до следующей отрисовки кадра. Это и есть ответ на классический вопрос «почему для анимации используют rAF, а не setTimeout(fn, 0)»: setTimeout не синхронизирован с кадром вообще (сработает раньше или позже отрисовки, вызывая дёрганость), rAF — синхронизирован by design.

Пример, который стоит уметь трассировать вслух

console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve()
  .then(() => {
    console.log('C');
  })
  .then(() => {
    console.log('D');
  });

queueMicrotask(() => {
  console.log('E');
});

async function fn() {
  console.log('F');
  await null;
  console.log('G');
}
fn();

console.log('H');

Результат: A F H C E G D B.

Почему именно так

  1. Синхронный проход. Стек выполняет код сверху вниз без остановок: A, затем внутри fn() код до первого await выполняется синхронно — F, затем H. await null откладывает продолжение (G) в microtask queue для любого значения после await, даже не-промиса — это работает одинаково что для await null, что для await somePromise.
    К моменту, когда стек опустел, microtask queue выглядит так: [C, E, G] — C зарегистрирован раньше E, а G позже обоих, потому что fn() вызван после queueMicrotask(E).
  2. Microtask queue выгребается полностью, в порядке постановки: C → печатает C, резолвит промис для .then(D), который встаёт в конец очереди. E → печатает E. G → печатает G. Теперь очередь доходит до D (он встал в очередь при выполнении обработчика .then(C)), выполняется — печатает D. Очередь пуста.
  3. Только когда microtask queue полностью пуста, event loop может перейти к следующей задаче из task queue — setTimeout: B.

Итог: A F H C E G D B. В браузере, в отличие от Node.js, между этим моментом и следующей задачей ещё может вклиниться шаг рендеринга — если что-то в DOM изменилось и подошло время кадра.

А если дело не в очереди? А вдруг блокирует сам код?

Всё выше — про то, как event loop планирует уже готовые к выполнению задачи. Но если сама синхронная функция работает долго — раскладка event loop не спасает: JS и рендеринг делят один и тот же главный поток.

В браузере это не «сервис висит», а интерфейс висит: тяжёлый синхронный forEach по большому массиву, JSON.parse на гигантском ответе, регулярка с катастрофическим бэктрекингом — всё это блокирует интерфейс целиком: скролл не реагирует, клики не срабатывают, анимация замирает, потому что рендеринг — это тоже шаг того же самого цикла. Пользователь не увидит ошибку в консоли — просто скажет, что «сайт завис».

На сеньорском интервью вопрос на этом месте обычно звучит так: «что вы будете делать, если тяжёлое вычисление блокирует интерфейс». Вариантов меньше, чем на сервере (нет cluster, нет смысла в отдельном воркер-сервисе для клиентского кода), но они реальные.

Web Workers

Настоящий отдельный поток со своим собственным event loop и своим стеком вызовов — DOM оттуда недоступен напрямую (Worker не может трогать интерфейс), обмен данными — через postMessage (структурное клонирование, с копированием) или Transferable-объекты (ArrayBuffer и подобные — передаются, а не копируются, исходный поток теряет доступ к переданным данным).

Главный поток остаётся полностью отзывчивым, пока воркер считает — это и есть основная причина использовать этот паттерн. Но постановка задачи в воркер и получение результата идут через сообщения. Из-за этого усложняются и отладка, и обработка ошибок. Плюс сам воркер — это отдельный файл со своим контекстом загрузки, что не всегда удобно упаковывать в существующий бандл.

SharedArrayBuffer позволяет делить память между главным потоком и воркером без копирования — но с 2018 года (после Spectre) требует, чтобы страница отдавалась с заголовками Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy (кросс-origin изоляция). То есть не всегда доступен без изменений на уровне сервера/хостинга — серьёзное ограничение на практике.

Чанкинг через requestIdleCallback/rAF

Тяжёлое вычисление режется на куски, между которыми управление явно отдаётся обратно event loop — requestIdleCallback (выполняет колбэк, когда браузер уверен, что простаивает, с дедлайном) или чанки, синхронизированные с requestAnimationFrame, если важно не сорвать кадр.

Не требует ни воркера, ни message-passing архитектуры — самый дешёвый вариант по трудозатратам. Но параллелизма это не добавляет: суммарное время вычисления не сокращается, чанкинг лишь сохраняет отклик интерфейса, а не ускоряет сам расчёт — для этого нужен воркер.

requestIdleCallback не поддержан в Safari — если важна кросс-браузерность, обычно подстраховываются через setTimeout как fallback.

TL;DR

  • Event loop и макрозадачи в браузере — часть спецификации HTML/WHATWG, не ECMAScript; микрозадачи через Promise — часть самой спецификации языка.
  • Модель цикла — одна задача из task queue за итерацию, затем полное опустошение microtask queue (включая вновь добавленные микрозадачи), и только потом — следующая задача.
  • Между задачами может вклиниться шаг рендеринга — requestAnimationFrame синхронизирован именно с ним, в отличие от setTimeout.
  • Если блокирует не очередь, а сама синхронная работа — страдает интерфейс целиком (рендеринг и JS делят один поток). Решение — Web Workers для реального параллелизма или requestIdleCallback/чанкинг через rAF, если задача не критична к скорости, только к отзывчивости интерфейса.

Вторая часть — про Node.js-специфику (process.nextTick, фазы libuv, архитектура при блокировке сервера): «Event Loop в Node.js».


💬  Обсудить статью можно в Telegram-канале →