⏱ Event Loop, микротаски, макротаски, requestAnimationFrame

💡 Основная идея

Event Loop не выполняет код. Это диспетчер: он проверяет, пуст ли стек вызовов, и затем перемещает следующую задачу из очереди в него.

Два вопроса, на которые отвечает эта заметка: почему setTimeout(fn, 0) всегда выполняется после всего синхронного кода и всех промисов? И почему бесконечная цепочка Promise.then() намертво замораживает вкладку, а бесконечный цикл requestAnimationFrame — нет?


🧭 Участники

  1. Call Stack (стек вызовов) — выполняет синхронный JS построчно. Пока он не пуст, ничего другого не выполняется.
  2. JS-движок (V8 и т.д.) — выполняет только стек вызовов. У него нет встроенного понятия setTimeout, DOM или fetch — ничего из этого не входит в спецификацию ECMAScript.
  3. Web API (браузер) / libuv (Node.js) — отдельная часть рантайма, которая фактически отсчитывает таймеры, слушает сетевые/DOM-события и подготавливает кадры.
  4. Очередь макротасковsetTimeout, setInterval, DOM-события, I/O. FIFO; Event Loop извлекает из неё по одной задаче за раз.
  5. Очередь микротасковPromise.then, queueMicrotask, MutationObserver. FIFO, но Event Loop полностью опустошает её, прежде чем делать что-либо ещё.
  6. Event Loop — сам диспетчер: в цикле проверяет «пуст ли стек вызовов?», извлекает следующую задачу, помещает её в стек.

🔑 Золотое правило

После каждой синхронной задачи Event Loop полностью опустошает очередь микротасков — включая микротаски, добавленные во время самого опустошения. Только когда очередь пуста, он переходит дальше (к рендерингу или к одному макротаску).

Разница не в «приоритете» — а в разной стратегии опустошения: микротаски означают «заверши всё, что накопилось, сколько бы там ни оказалось»; макротаск означает «возьми ровно одну вещь и верни управление диспетчеру».

Одна полная итерация цикла:

Call Stack (весь синхронный код)
  → полностью опустошить очередь микротасков (даже если она пополняется во время опустошения)
      → колбэки requestAnimationFrame (снимок ровно этого кадра)
          → Render: Layout → Paint → Composite
              → взять ОДИН макротаск → выполнить его
                  → снова опустошить очередь микротасков
                      → ... повторить

🎞 requestAnimationFrame

requestAnimationFrame(cb) просит браузер «выполнить это прямо перед отрисовкой следующего кадра». Это не таймер, и он не даёт гарантий по точным миллисекундам — он привязан к реальному циклу обновления экрана (~60/сек).

Порядок выполнения с учётом rAF:

Promise.resolve().then(() => console.log("promise"))
requestAnimationFrame(() => console.log("raf"))
setTimeout(() => console.log("timeout"), 0)
 
// promise → raf → timeout

❄️ Почему бесконечный Promise.then() замораживает вкладку, а бесконечный rAF — нет

Это не совпадение — обе очереди работают по принципиально разной механике.

Очередь микротасков проверяется динамически. Перед каждым шагом Event Loop спрашивает «очередь пуста?». Если выполняющийся сейчас микротаск добавляет в очередь новый, тот присоединяется к очереди и выполняется в этом же проходе. Для бесконечной цепочки условие выхода («пусто») никогда не наступает:

function loop() {
  Promise.resolve().then(loop) // каждый .then() порождает следующий
}
loop()
// Event Loop никогда не доходит до рендеринга, макротасков или кликов — вкладка мертва

Очередь rAF для кадра — это снимок (батч), зафиксированный в начале обработки этого кадра. Всё, что регистрируется через requestAnimationFrame(...) во время выполнения текущего батча, физически не может попасть в этот же батч — только в следующий кадр:

function animate() {
  x++
  requestAnimationFrame(animate) // попадёт в СЛЕДУЮЩИЙ кадр, не в этот
}
requestAnimationFrame(animate)
// Render (Layout → Paint → Composite) гарантированно происходит между кадрами

Поэтому бесконечная рекурсия через rAF не блокирует браузер: рендер всегда происходит между итерациями. Ключевое различие не в том, насколько быстро выполняется каждый колбэк — а в том, что у очереди микротасков нет границы-снимка, а у батча rAF она есть.


🧠 Одним предложением

Условие выхода Event Loop для очереди микротасков — «пусто», а самопополняющаяся очередь никогда его не достигает. Очередь rAF же выходит через снимок: всё, что добавляется во время батча, откладывается на следующий, независимо от скорости выполнения.

Browser Rendering Pipeline — что происходит после опустошения очереди микротасков browser