Topics
Concurrency & Async

The Event Loop: Microtasks vs Macrotasks

How JavaScript runs async code on a single thread, why promise callbacks beat setTimeout, and how to predict the output of any ordering puzzle.

Intermediate·11 min read·Updated Sep 27, 2026

JavaScript runs your code on one thread, but it never blocks while waiting for a timer, a network response or a click. The event loop is the scheduler that makes this work: it runs one task to completion, drains every queued microtask, gives the browser a chance to render, then picks the next macrotask. Once you can see those queues, every "what does this print?" puzzle becomes mechanical.

Why it matters

Almost every hard-to-reproduce frontend bug lives here: a state update that shows up one frame late, a spinner that never paints because a loop hogs the thread, a "flash" of stale UI, or a Node service whose latency spikes because one request starved the loop. Understanding the loop is also how frameworks work: React batching, Vue's nextTick, and every "defer this until after paint" trick are just choices about which queue to put work on.

The model: one stack, two kinds of queue

Three pieces are enough to reason about everything that follows.

  • Call stack — where synchronous code runs. A function call pushes a frame; returning pops it. The loop never interrupts a running frame.
  • Macrotask queue (the spec just says "task queue") — timers, I/O callbacks, UI events, setImmediate in Node, MessageChannel messages, script execution itself. One task runs per turn of the loop.
  • Microtask queue — promise reactions, queueMicrotask, MutationObserver callbacks, and the continuation after every await. Drained completely after each task, before anything else happens.
Macrotask queuetimerclickfetchone per turnCall stackrun task to endMicrotask queuethenawaitqμtdrain ALL12Render (maybe)style · layout · paint34 · next turn
One turn of the event loop. The microtask checkpoint runs after every task and again after every microtask that queues more, until the queue is empty. Rendering only gets a chance between tasks.

The single most important rule: the microtask queue is drained to empty before the loop moves on. If a microtask queues another microtask, that one runs too, in the same checkpoint. Macrotasks never get that treatment. One runs, then the loop checks microtasks, then maybe renders, then takes the next task.

Worked example: predict the output

ordering.js
console.log('A');

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

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

queueMicrotask(() => console.log('E'));

console.log('F');

Walk it the way the engine does.

  1. 1

    The script itself is a macrotask. It runs top to bottom: A prints. setTimeout hands B to the timer, which will enqueue a macrotask later. The then callback and E are queued as microtasks. F prints. The stack is now empty.

  2. 2

    Microtask checkpoint. Queue is [C-callback, E]. Run the first: C prints and it queues D at the back: [E, D]. Run E. Run D. Queue empty, checkpoint ends.

  3. 3

    The browser may render. Then the next macrotask: the timer callback. B prints.

stack done
AF
microtasks
CEDD queued by C, still same checkpoint
macrotasks
BsetTimeout 0 is never 0

Where async/await fits

await is syntax over promises, so it uses the microtask queue. Everything before the first await in an async function runs synchronously, as part of the current task. The line after the await is the continuation, and it is queued as a microtask once the awaited value settles.

async function
async function run() {
  console.log('1');
  await null;
  console.log('3');
}

run();
console.log('2');
// 1 2 3
what it desugars to
function run() {
  console.log('1');
  return Promise.resolve(null)
    .then(() => {
      console.log('3');
    });
}

run();
console.log('2');
// 1 2 3

One subtlety worth knowing for interviews: await somePromise takes exactly one microtask tick in modern engines (since V8 7.2 / Node 12). Older engines wrapped the value in extra promises and needed three ticks, which is why some blog posts from 2018 show different orderings. Do not memorize tick counts; reason about "continuation is a microtask" and you will be right in practice.

Node.js: the same idea, with more phases

Node's loop (libuv) cycles through phases: timers, pending callbacks, poll (I/O), check (setImmediate), close callbacks. Between every callback, Node drains two extra queues in a fixed order: first process.nextTick, then promise microtasks.

APIQueueRuns
process.nextTicknextTick queueBefore promise microtasks, after the current callback. Highest priority of all.
Promise.then / awaitmicrotask queueAfter nextTick queue drains, before the loop advances.
setTimeout(fn, 0)timers phaseNext loop iteration at the earliest. Clamped to ≥1 ms.
setImmediatecheck phaseAfter the poll phase of the current iteration. Inside an I/O callback it always beats setTimeout(0).

Rendering, long tasks and why the UI freezes

The browser can only paint between macrotasks. A task that runs for 300 ms, or a microtask chain that keeps queuing more work, blocks paint for that long. That is what a "long task" in DevTools is, and why a spinner you toggled on before a heavy loop never appears: the style you set is real, but the render step has not had its turn yet.

spinner never shows
spinner.hidden = false;
heavyWork();        // 400 ms sync
spinner.hidden = true;
// paint happens here — with
// the spinner already hidden
yield to the loop first
spinner.hidden = false;
await new Promise(r => setTimeout(r, 0));
// a macrotask boundary: paint ran
heavyWork();
spinner.hidden = true;

A promise await would not have worked in the second version. Microtasks run before render, so await Promise.resolve() yields to other microtasks but never lets the browser paint. Use a macrotask (setTimeout, MessageChannel, or scheduler.yield() where available) when you want a frame to get through. For animation work, requestAnimationFrame runs right before the next paint, which is a third position in the cycle that is neither queue.

Pitfalls

  • Treating setTimeout(fn, 0) as “run immediately after this line”

    It runs after the current task, after every queued microtask, after a possible render, and after any timers that were already due. In browsers nested timers are clamped to at least 4 ms. It means "later", never "now".

  • Assuming a promise callback can interleave with sync code

    A then callback can never run in the middle of a synchronous block, even if the promise is already resolved. It always waits for the stack to empty. This is also why a resolved promise still returns to the caller before its handlers fire.

  • Infinite microtask chains

    A function that re-queues itself with queueMicrotask or a never-settling recursive then chain starves rendering and every timer, because the checkpoint never ends. Re-queue with a macrotask if you need to loop.

  • Expecting the microtask order to survive across different tasks

    Microtasks queued inside a click handler run at the end of that click task, not at the end of the script. Two separate tasks each get their own checkpoint, so ordering across them depends on which task ran first, not on when the promise was created.

Interview questions

Q1Why does a Promise.then callback run before a setTimeout(fn, 0) callback even when the timeout was registered first?

Promise reactions go on the microtask queue, which is drained completely as soon as the current task finishes. The timer enqueues a macrotask, and the loop only takes the next macrotask after the microtask checkpoint and a possible render. Registration order does not matter across the two queues.

Q2What is the difference between a microtask and a macrotask?

Both are units of work the loop runs to completion, but they come from different queues with different draining rules. One macrotask runs per loop turn; the microtask queue is emptied entirely after each task, including microtasks queued during the drain. Rendering can only happen between macrotasks.

Q3Does await block the thread?

No. It suspends only the async function. The engine returns to the caller synchronously, and the code after the await is scheduled as a microtask once the awaited promise settles. Everything else on the page keeps running.

Q4You set a loading spinner and then run a heavy computation, but the spinner never appears. Why, and how do you fix it?

The DOM change and the computation are in the same task, so the render step runs only after both finish, by which point the spinner is hidden again. Yield with a macrotask boundary before the heavy work (a zero timeout, MessageChannel, scheduler.yield), move the computation to a Web Worker, or chunk it across frames. Awaiting a resolved promise is not enough, because microtasks run before paint.

Q5In Node, what is the order of process.nextTick, Promise.then, setTimeout(0) and setImmediate inside an I/O callback?

nextTick first, then the promise callback (both run before the loop advances), then setImmediate (the check phase comes right after poll, where I/O callbacks run), then the timer on the next iteration. Outside an I/O callback, setTimeout(0) versus setImmediate is non-deterministic because it depends on whether the 1 ms timer is due when the loop starts.

Q6How can a microtask starve the UI, and how would you detect it?

If microtasks keep queuing microtasks, the checkpoint never ends, so no render or timer runs. In DevTools it shows up as a single long task in the Performance panel and as Interaction to Next Paint (INP) regressions in field data. The fix is to break the chain with a macrotask or move the work off the main thread.

Q7Where does requestAnimationFrame fit relative to the two queues?

Neither. rAF callbacks run as part of the render step, right before style, layout and paint, so they run after microtasks but at most once per frame. That makes them the right place for DOM reads and writes that must line up with a frame, and the wrong place for work that needs to happen more often than the display refreshes.

Key takeaways
  • One task at a time. The stack must be empty before anything queued can run.
  • Microtasks (promises, await, queueMicrotask) drain completely after every task, including ones queued during the drain.
  • Macrotasks (timers, I/O, events) run one per loop turn, and rendering only happens between them.
  • setTimeout(fn, 0) means "after the current task, its microtasks and maybe a paint", never "now".
  • In Node, process.nextTick beats promise microtasks, and setImmediate beats setTimeout inside I/O callbacks.
  • To let the browser paint, yield with a macrotask; awaiting a promise is not enough.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free