Skip to content
C Codeloom
JavaScript

The JavaScript Event Loop: Microtasks vs Macrotasks

Build an accurate mental model of the JavaScript event loop, the call stack, web APIs, the task and microtask queues, and why setTimeout zero is not really zero.

·7 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • What the call stack, web APIs, and queues actually do
  • The difference between microtasks and macrotasks
  • Why promise callbacks always beat setTimeout zero
  • How rendering, input, and timers fit into the loop
  • How to use this mental model to diagnose real bugs

Prerequisites

JavaScript is single threaded. There is one call stack and one thread of execution. Yet your code happily handles clicks, timers, network responses, and animations without freezing. The thing that makes that possible is the event loop. It is also the most commonly misunderstood part of the language. Here is a mental model that is accurate enough for almost every real problem you will hit.

The pieces of the machine

There are four pieces you need to know about.

The call stack is where currently executing JavaScript lives. Function calls push frames on, returns pop them off. When the stack is empty, the engine is idle.

Web APIs (or Node APIs) are everything the host gives you that is not pure JavaScript. Timers, network requests, the DOM, file I/O. These run outside the JavaScript thread.

The task queue, sometimes called the macrotask queue, holds work that the host wants the engine to run later. setTimeout callbacks, I/O completions, and message events end up here.

The microtask queue holds promise callbacks, queueMicrotask jobs, and a few other things. It has higher priority than the task queue.

The event loop is the supervisor. Its rule is simple: when the call stack is empty, drain the microtask queue completely, then take one task from the task queue and run it. Repeat forever.

A first walkthrough

console.log("A");

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

Promise.resolve().then(() => console.log("C"));

console.log("D");

The output is A, D, C, B. Walk it step by step.

The engine pushes the top-level script onto the stack. It logs A. It calls setTimeout, which hands off to the host and schedules a callback to land in the task queue after at least zero milliseconds. It creates a resolved promise and queues the .then callback in the microtask queue. It logs D. The script finishes and the stack empties.

Now the event loop runs. The microtask queue has one job, so it drains: C is logged. The microtask queue is empty. The loop picks one task from the task queue: the timer callback. B is logged.

That ordering, microtasks before any new task, is the most important rule in the model. It is also the reason you can chain a hundred promises and still get every callback before a single setTimeout(..., 0) fires.

The visual model

Picture three boxes side by side.

+---------+   +---------------+   +---------------+
|  STACK  |   |  MICROTASKS   |   |    TASKS      |
|         |   |               |   |               |
+---------+   +---------------+   +---------------+

The engine only ever runs code from the stack. The web APIs box on the side moves callbacks into either the microtask or task queue when they are ready. The event loop’s only job is to feed the stack: when it is empty, drain microtasks, run one task, repeat.

Microtasks vs macrotasks in detail

Microtasks include:

  • Promise.then / catch / finally callbacks
  • queueMicrotask jobs
  • MutationObserver callbacks
  • await continuations (because await is sugar over promises)

Macrotasks include:

  • setTimeout and setInterval callbacks
  • setImmediate (Node)
  • I/O completions
  • MessageChannel messages
  • UI events like clicks and input

The split exists because microtasks are meant to feel synchronous-ish. They run as soon as the current script is done, before the browser does anything else. Macrotasks let the browser interleave rendering, layout, and input handling.

Why setTimeout zero is not zero

People reach for setTimeout(fn, 0) when they want to “defer” something. It works, but it does not do what most people think.

console.log("start");
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("microtask"));
console.log("end");

// start
// end
// microtask
// timer

setTimeout(fn, 0) says “run fn as soon as possible, but after at least 0 ms and after everything currently scheduled.” The browser is also free to clamp the minimum to four milliseconds after a few nested timers, and to delay even longer if the tab is in the background. If you want “run after the current synchronous code but as soon as possible,” queueMicrotask is closer to what you want.

queueMicrotask(() => console.log("soon"));

That callback runs after the current script and before the next paint and before any timer.

await is just a promise then

This trips people up constantly. await does not pause anything in a magical way. It schedules a microtask continuation.

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

run();
console.log("2");

// 1
// 2
// 3

await null evaluates to a resolved promise. Everything after the await becomes a microtask. So the function returns to its caller after logging 1, the script logs 2, the stack empties, and the microtask runs, logging 3. If you understand this, you understand the relationship between async/await and the loop.

Where rendering fits in

In browsers, rendering happens between tasks, not between microtasks. The simplified loop is:

  1. Run one task.
  2. Drain the microtask queue.
  3. If a frame is due, render.
  4. Repeat.

This is why a long chain of synchronous microtasks can starve the renderer. If you schedule microtask after microtask in a loop, the browser cannot paint between them. The screen freezes, the tab feels janky, and devtools blame your script.

function microtaskBomb() {
  queueMicrotask(microtaskBomb);
}
microtaskBomb(); // freezes the tab

A setTimeout loop, on the other hand, yields between tasks. The page still paints. The takeaway: if you have a long job to do, break it into tasks (with setTimeout or MessageChannel), not microtasks.

A real bug this model explains

You fetch some data and update a counter. The counter does not update until after a 100 ms setTimeout you have running somewhere else. Why?

async function load() {
  const res = await fetch("/data");
  const data = await res.json();
  state.count = data.count;
  render(); // updates the DOM
}

You expected render to fire immediately after fetch resolves. It does, in microtask time. But if your fetch was queued behind a long-running task, or your render function itself schedules animations through requestAnimationFrame, the visible update lands on the next paint. Knowing that microtasks run before paint but requestAnimationFrame runs at paint clears up most confusion here.

Node has its own twist

Node uses libuv and adds a few extra phases (timers, pending callbacks, poll, check, close). process.nextTick is even higher priority than microtasks: it runs after the current operation, before the microtask queue. setImmediate lands in the check phase. For most application code, the browser model is close enough. When you write Node infrastructure, learn the phases. Otherwise, treat microtasks-before-tasks as the rule and you will be right most of the time.

How to reason about real code

When you see async code, ask three questions.

What does this synchronously schedule? Anything that pushes onto the stack, calls a web API, or returns a promise.

What ends up in the microtask queue? Every .then, every await continuation.

What ends up in the task queue? Every timer, every fetched response handler, every user event.

Walk the loop one step at a time. The model is small enough that you can simulate it on paper for any snippet under twenty lines, and that is usually all you need to find the bug. This pairs especially well with how promises chain.

Wrap up

The event loop is not a mystery. It is a supervisor with one rule: empty stack, drain microtasks, run one task, repeat. Microtasks are for “do this right after the current code.” Macrotasks are for “do this on a future turn of the loop.” Promise callbacks and await continuations are microtasks. Timers, fetched responses, and user input are tasks. Internalize the loop, and async JavaScript stops being a guessing game and starts being predictable.