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.
Почему именно так
- Синхронный проход. Стек выполняет код сверху вниз без остановок:
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). - Microtask queue выгребается полностью, в порядке постановки:
C→ печатаетC, резолвит промис для.then(D), который встаёт в конец очереди.E→ печатаетE.G→ печатаетG. Теперь очередь доходит доD(он встал в очередь при выполнении обработчика.then(C)), выполняется — печатаетD. Очередь пуста. - Только когда 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-канале →