The JavaScript Event Loop Explained: How Microtasks and Macrotasks Are Ordered

Why does Promise.then run before setTimeout(0)? A walkthrough of the call stack, task queue, and microtask queue with runnable examples, plus how browsers and Node.js differ and how to keep long work from freezing the UI.

Why does this print 1 → 4 → 3 → 2?

Let's start with a quiz.

console.log("1");

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

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

console.log("4");

The setTimeout delay is 0, so you might expect it to run right away. The actual output is:

1
4
3
2

Promise.then runs before setTimeout(0). To understand why, you need to know how the event loop works.


What is the event loop?

The event loop is what lets single-threaded JavaScript handle asynchronous work: it decides the order between "code to run now" and "callbacks to run later."

A JavaScript engine runs one piece of code at a time. It only looks like it's juggling timers, network requests, and clicks at once because the waiting is handed off to the browser (or Node.js), and the callbacks of finished work are lined up in queues and run one by one.

These are the pieces involved:

PieceRoleExamples
Call stackWhere currently running functions pile upRegular function calls
Web APIs / runtime APIsHandle slow work outside the engineTimers, fetch, DOM events
Task queue (macrotasks)Where callbacks of finished async work waitsetTimeout, setInterval, event handlers
Microtask queueWhere higher-priority callbacks waitPromise.then/catch/finally, code after await, queueMicrotask

The core ordering rule

The event loop repeats this cycle forever:

  1. Take one task from the task queue and run it (at first, the whole script is one task).
  2. When the call stack is empty, run microtasks until the microtask queue is completely empty.
  3. Repaint the screen if needed (rendering). requestAnimationFrame callbacks run in this step too.
  4. Go back to step 1.

Step 2 is the key.

Tasks run one at a time. Microtasks run all at once, until the queue is empty.

Now replay the quiz:

  • Script runs (a task) → prints 1 → the setTimeout callback goes to the task queue → the then callback goes to the microtask queue → prints 4
  • Script ends, call stack is empty → drain the microtask queue → prints 3
  • Next task → prints 2

Practice with examples

1. Microtasks created inside a task

setTimeout(() => {
  console.log("timeout 1");
  Promise.resolve().then(() => console.log("micro in timeout 1"));
}, 0);

setTimeout(() => console.log("timeout 2"), 0);
timeout 1
micro in timeout 1
timeout 2

Even though both timers are already queued, the microtask created inside the first task runs right after that task finishes. Only then does the loop move on to the second task.

2. async / await

Code after await is equivalent to a then callback, which means it is scheduled as a microtask.

async function foo() {
  console.log("foo start");
  await null;
  console.log("foo end");
}

console.log("script start");
foo();
console.log("script end");
script start
foo start
script end
foo end

An async function runs synchronously until its first await. That's why foo start prints before script end. It's easy to assume an async function is asynchronous from the very first line, but it isn't.


Browser vs Node.js

Node.js also uses an event loop, but it's built on libuv, splits the loop into more phases, and has APIs browsers don't.

BrowserNode.js
Rendering stepYes (requestAnimationFrame)No
Extra queue-process.nextTick queue (runs before Promises)
Extra API-setImmediate (runs in the check phase)
// Node.js, CommonJS module
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
nextTick
promise
timeout / immediate (their order can change from run to run)

In the main module, the order of setTimeout(0) and setImmediate is not guaranteed. Inside an I/O callback (for example, the callback of fs.readFile), setImmediate always runs first. And in ES modules (.mjs), top-level code already runs inside a microtask, so promise can print before nextTick.


Problems you'll hit in real code

1. Heavy synchronous work freezes the screen

Rendering only happens between tasks. If one task takes a long time, clicks, scrolling, and screen updates all freeze until it's done.

// ❌ Processing 100,000 items in one go freezes the UI
items.forEach(processItem);

Split the work into chunks and create task boundaries along the way so the browser can breathe. Briefly handing the main thread back like this is called yielding.

// ✅ Hand off to a new task every so often so rendering and input can run
const yieldToMain = () => new Promise((resolve) => setTimeout(resolve, 0));

async function processAll(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    if (i % 500 === 0) await yieldToMain();
  }
}

Recent Chromium-based browsers provide scheduler.yield() for exactly this. Check for support and use it where available.

2. Microtasks don't yield

If you swap setTimeout for Promise.resolve() in the code above, it stops working. Microtasks keep running until the queue is empty, so rendering never gets a chance in between. If microtasks keep scheduling new microtasks, the page freezes completely, just like while (true).

// ❌ Rendering never happens
function loop() {
  Promise.resolve().then(loop);
}
loop();

3. "0ms" doesn't mean "now"

setTimeout(fn, 0) means "put this in the task queue after at least 0ms." It only runs after every task and microtask ahead of it is done, and browsers may raise the minimum delay of nested timers to 4ms. Don't rely on it for precise timing.


Summary: why this is worth knowing

What you seeThe event loop explanation
A then callback runs before setTimeout(0)Microtasks run before the next task
The top of an async function runs immediatelyIt's synchronous until the first await
The screen freezes during a big loopOne task is blocking the chance to render
Still frozen after splitting with PromiseAll microtasks run before rendering

Once you understand the event loop, you spend far less time on "why are these logs in this order?", and you can quickly pin down why a loading spinner won't spin or input feels laggy. When async code doesn't behave the way you expect, start by asking: "Is this callback a task or a microtask?"