Статья Асинхронность и 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, fetch-колбэки, postMessage. После каждой макрозадачи браузер может сделать рендер.
Микрозадачи (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, но без уступки рендеру.
Где рисует браузер
Цикл выглядит так:
- Выполнить одну макрозадачу.
- Слить все микрозадачи до дна.
- (Опционально) Если пришло время кадра (~16 мс) — запустить
requestAnimationFrame-колбэки и перерисовать страницу. - Вернуться к шагу 1.
requestAnimationFrame стоит перед рендером, но после микрозадач текущего тика — это правильное место для анимаций, потому что код выполнится непосредственно перед отрисовкой кадра.
requestAnimationFrame(() => {
// здесь изменять DOM для анимации — без промежуточного кадра
element.style.transform = `translateX(${x}px)`;
});
Длинные задачи блокируют 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 115+) или 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.
Коротко
- Браузер выполняет JavaScript в одном потоке: пока стек занят — ни рендера, ни событий.
- Event loop: одна макрозадача → все микрозадачи → (опционально) рендер кадра → следующая макрозадача.
- Микрозадачи (
Promise.then,queueMicrotask) выполняются до следующей макрозадачи и до рендера. - Бесконечная цепочка микрозадач голодит рендер — страница зависает.
requestAnimationFrameзапускается перед рендером кадра — правильное место для анимаций.- Задачи дольше ~50 мс блокируют UI; разбивают через
setTimeout(step, 0)илиscheduler.yield(). - React 18 батчит
setStateдаже внутриsetTimeout;useLayoutEffectработает до рендера кадра.
Что почитать дальше
- Асинхронность и event loop в TypeScript — вводная: однопоточность, Promise, async/await.
- this: контекст вызова — почему this теряется в колбэках setTimeout и addEventListener.
- События DOM: всплытие, погружение, делегирование — как события попадают в task queue.
- Производительность рендеринга — как длинные задачи влияют на Core Web Vitals.