← назад к разделу

Статья Асинхронность и event loop в TypeScript объясняет основу: один поток, неблокирующий ввод-вывод, очередь задач. Здесь — браузерная специфика: макрозадачи против микрозадач, рендер между тиками и что происходит, когда задача слишком длинная.

Обязательно

Call stack, task queue и event loopспросят на собеседовании

Браузер выполняет JavaScript в одном потоке. Всё, что запущено прямо сейчас, лежит в call stack (стеке вызовов). Когда стек пуст, event loop берёт следующую задачу из task queue (очереди задач).

живой пример

console.log("1"); // синхронно — сразу в стек

setTimeout(() => console.log("3"), 0); // в очередь — после "2"

console.log("2"); // синхронно — в стек

// Вывод: 1, 2, 3
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

setTimeout с задержкой 0 не значит «немедленно» — значит «не раньше, чем освободится стек и отработает текущий тик».

Макрозадачи и микрозадачиспросят на собеседовании

Event loop поддерживает две очереди, и порядок их обработки разный.

Макрозадачи (task queue): setTimeout, setInterval, события DOM, postMessage, приход сетевого ответа (а вот .then у fetch это уже микрозадача). После каждой макрозадачи браузер может сделать рендер.

Микрозадачи (microtask queue): Promise.then/catch/finally, queueMicrotask, MutationObserver. Микрозадачи выполняются сразу после текущей задачи, до следующей макрозадачи и до рендера.

живой пример

console.log("1");

setTimeout(() => console.log("4"), 0); // макрозадача

Promise.resolve()
  .then(() => console.log("2"))  // микрозадача
  .then(() => console.log("3")); // ещё микрозадача

// Вывод: 1, 2, 3, 4
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Разбор: "1" — синхронный код. Затем стек пуст: браузер сливает все микрозадачи ("2", "3"), и только потом берёт макрозадачу ("4").

Голодание: когда микрозадачи не дают рендеру дышать

Если из микрозадачи бесконечно порождать новые — рендер никогда не наступит. Браузер не может вставить кадр до опустошения microtask queue.

function flood() {
  Promise.resolve().then(flood); // новая микрозадача из микрозадачи
}
flood(); // страница зависнет — рендер заблокирован

queueMicrotask — это не инструмент для «отложить на чуть позже»; он быстрее setTimeout, но без уступки рендеру.

Где рисует браузер

Цикл выглядит так:

  1. Выполнить одну макрозадачу.
  2. Слить все микрозадачи до дна.
  3. (Опционально) Если пришло время кадра (~16 мс) — запустить requestAnimationFrame-колбэки и перерисовать страницу.
  4. Вернуться к шагу 1.

requestAnimationFrame стоит перед рендером, но после микрозадач текущего тика — это правильное место для анимаций, потому что код выполнится непосредственно перед отрисовкой кадра.

requestAnimationFrame(() => {
  // здесь изменять DOM для анимации — без промежуточного кадра
  element.style.transform = `translateX(${x}px)`;
});
одна макрозадача стек пуст все микрозадачи до дна очередь микрозадач пуста колбэки requestAnimationFrame пришло время кадра, около 16 мс рендер кадра

Один оборот цикла: после рендера браузер берёт следующую макрозадачу. Пока микрозадачи порождают новые, до кадра дело не доходит.

Длинные задачи блокируют UIспросят на собеседовании

Если макрозадача выполняется дольше ~50 мс — браузер не успевает рисовать кадры (60 fps = 16 мс на кадр), страница «лагает», клики перестают отзываться.

// Плохо: блокирует поток на сотни миллисекунд
function processHuge(items) {
  items.forEach(heavyWork);
}

Разбивка на чанки через setTimeout — стандартный приём. После каждого чанка браузер получает шанс отрисовать кадр и обработать события.

function processInChunks(items, chunkSize = 100) {
  let i = 0;
  function step() {
    const end = Math.min(i + chunkSize, items.length);
    for (; i < end; i++) heavyWork(items[i]);
    if (i < items.length) setTimeout(step, 0); // уступаем loop
  }
  step();
}

Современный вариант — scheduler.yield() (Chrome 129+) или scheduler.postTask(): планировщик сам выбирает, когда уступить, с учётом приоритетов.

Практические следствия для React

Батчинг обновлений. React 18 автоматически объединяет несколько setState в один рендер, даже внутри setTimeout или Promise.then. До React 18 батчинг работал только внутри синхронных обработчиков.

// React 18 — оба setState дадут один ре-рендер
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 0);

Почему setState «асинхронный». setState не обновляет state мгновенно. Обновление ставится в очередь и применяется после завершения текущего обработчика события — это и есть батчинг. После коммита React обновляет DOM, и браузер рисует следующий кадр.

useEffect и микрозадачи. Колбэки useEffect запускаются после рендера, асинхронно (как макрозадача). useLayoutEffect — синхронно после DOM-мутации, до рендера кадра. Именно поэтому useLayoutEffect нужен для измерения DOM: в момент его выполнения браузер ещё не нарисовал кадр.

Ловушка: что выведет кодспросят на собеседовании

живой пример

console.log("A");

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

Promise.resolve().then(() => {
  console.log("C");
  setTimeout(() => console.log("D"), 0);
});

console.log("E");

// Вывод: A, E, C, B, D
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Разбор: A, E — синхронно. Стек пуст → микрозадачи: C + регистрация второго setTimeout. Затем макрозадачи по порядку: B, D.

Дополнительно: при первом чтении можно пропустить

Глубже: Web Worker, когда чанки не спасаютрасширенноеспросят на собеседовании

Разбивка на чанки помогает, пока работу можно прервать: цикл по массиву уступает, браузер рисует кадр, цикл продолжается. Но есть задачи, которые нельзя порезать на куски по пять миллисекунд: разбор файла на 50 МБ, сжатие картинки перед загрузкой, расчёт маршрута. Их выносят в Web Worker: отдельный поток со своим event loop, без доступа к DOM, но с полноценным JavaScript.

// main.js
const worker = new Worker(new URL("./parse.worker.js", import.meta.url), { type: "module" });
worker.postMessage(file);
worker.onmessage = (e) => render(e.data);

// parse.worker.js
self.onmessage = async (e) => {
  const rows = await parseCsv(e.data); // хоть десять секунд: главный поток свободен
  self.postMessage(rows);
};

Обмен идёт сообщениями, и данные копируются, поэтому гонять туда-сюда мегабайты на каждый клик не стоит: большие буферы передают с правом владения (postMessage(buf, [buf])). Проверка, что воркер нужен: задача дольше 50 мс, её нельзя прервать и ей не нужен DOM. Если хоть одно условие не выполнено, хватит чанков или scheduler.yield().

Коротко

  • Браузер выполняет JavaScript в одном потоке: пока стек занят — ни рендера, ни событий.
  • Event loop: одна макрозадача → все микрозадачи → (опционально) рендер кадра → следующая макрозадача.
  • Микрозадачи (Promise.then, queueMicrotask) выполняются до следующей макрозадачи и до рендера.
  • Бесконечная цепочка микрозадач голодит рендер — страница зависает.
  • requestAnimationFrame запускается перед рендером кадра — правильное место для анимаций.
  • Задачи дольше ~50 мс блокируют UI; разбивают через setTimeout(step, 0) или scheduler.yield().
  • React 18 батчит setState даже внутри setTimeout; useLayoutEffect работает до рендера кадра.

Что почитать дальше