Микро- и макро-таски (tasks) в JavaScript: порядок

В чем разница между микро и макро-тасками в JavaScript?

Теория: что и когда выполняется

JavaScript выполняет синхронный код «до конца» (пока стек вызовов не опустеет), а всё асинхронное раскладывается по очередям и затем подхватывается циклом событий (event loop).
Существует как минимум две разные очереди: очередь task (обычные задачи, иногда их называют «макро-тасками») и очередь microtask (микрозадачи).

В спецификациях и документации чаще используется термин task (задача) и microtask (микрозадача); слово «макро-таска» — распространённое разговорное обозначение обычной task.

Task — это то, что планируется механизмами платформы: старт выполнения скрипта, обработка событий, срабатывание setTimeout()/setInterval() и т.п.; всё это попадает в очередь задач (task queue).
Microtask — это короткая функция, которая выполняется после того, как завершится код, который её создал, и когда стек вызовов пуст, но при этом — до того, как управление вернётся в event loop для выбора следующей task.

Ключевое правило порядка такое: после завершения одной task (например, выполнения всего «тела» скрипта или колбэка таймера) выполняются microtask до полного опустошения очереди microtask, и только потом начинается следующая task.
Если microtask во время выполнения добавляет ещё microtask, они также выполняются в этом же проходе до полного опустошения очереди; процесс продолжается, пока очередь не станет пустой.

Поскольку microtask могут бесконечно порождать новые microtask, существует риск «захватить» поток выполнения и надолго не дать event loop перейти к следующей task (таймерам, событиям, а также действиям, связанным с обновлением интерфейса).

Упрощённая схема одного тика

[синхронный код / одна task]
          |
          v
[очистка microtask queue до пустоты]
          |
          v
[взятие следующей task из task queue]
          |
         ...

Важно: «приоритет» microtask проявляется не потому, что они «важнее по времени», а потому что платформа обязана очистить очередь microtask перед тем, как брать следующую task.

Таблица: типичные источники задач

Что планирует выполнениеКуда попадаетПримеры
Таймеры/интервалыtask queuesetTimeout(fn, 0), setInterval(fn, 1000)
События и их обработчикиtask queueобработка click, message и т.п. как отдельные задачи
Реакции промисовmicrotask queuePromise.resolve().then(...), .catch(...), .finally(...)
Явная постановка микрозадачиmicrotask queuequeueMicrotask(() => ...)
Некоторые Web APImicrotask queueнапример, колбэки Mutation Observer

Примеры: Promise.then vs setTimeout(0)

Пример 1 — ожидается порядок: синхронный код → microtask → таймеры.

console.log('start');

setTimeout(() => {
  console.log('timeout');
}, 0);

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

console.log('end');

Ожидаемый порядок вывода: start, end, promise, timeout.
Причина: обработчик .then(...) попадает в очередь microtask и выполняется после завершения текущего синхронного кода, а setTimeout ставит колбэк в очередь task и будет взят только после очистки очереди microtask.

Пример 2 — microtask внутри task: микрозадача «вклинивается» между задачами, но не прерывает текущий синхронный участок.

setTimeout(() => {
  console.log('timer-1 start');

  queueMicrotask(() => {
    console.log('microtask inside timer-1');
  });

  console.log('timer-1 end');
}, 0);

setTimeout(() => {
  console.log('timer-2');
}, 0);

Ожидаемая логика: сначала выполняется одна timer-task (здесь: timer-1 start, затем timer-1 end), далее перед тем как перейти ко второй timer-task, очередь microtask очищается, поэтому строка microtask inside timer-1 должна появиться до timer-2.

Пример 3 — опасная ситуация: microtask могут «голодать» задачи, если постоянно добавлять новые microtask.

let i = 0;

function spinMicrotasks() {
  queueMicrotask(() => {
    i += 1;
    if (i < 1_000_000) {
      spinMicrotasks();
    }
  });
}

spinMicrotasks();

setTimeout(() => {
  console.log('timeout after microtasks');
}, 0);

Смысл примера: setTimeout будет ждать, пока очередь microtask не опустеет; если очередь долго не пустеет из‑за самопополнения, таймер (и другие задачи) могут выполняться с большой задержкой.

Итого: корректное утверждение — 4; microtask (обработчики промисов, queueMicrotask) выполняются сразу после завершения текущего синхронного кода и полностью очищаются перед переходом к следующей task (например, setTimeout).