DEV Community

Cover image for Why does setTimeout(0) run after your Promise? The event loop, step by step
Thới Hải Đức
Thới Hải Đức

Posted on

Why does setTimeout(0) run after your Promise? The event loop, step by step

Here's a small JavaScript puzzle. What order do these letters print in?

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));

async function run() {
  console.log("D");
  await null;
  console.log("E");
}

run();
console.log("F");
Enter fullscreen mode Exit fullscreen mode

Take a guess before you scroll.

.

.

.

The answer is:

A D F C E B
Enter fullscreen mode Exit fullscreen mode

B has a delay of 0, but it prints last. To see why, you need to know four things.

The four pieces

The call stack holds the code running right now. JavaScript runs one piece of code at a time on the main thread, and it finishes the current job before starting another.

Host APIs are provided by the browser or Node, not the JavaScript language. Timers are one example. They wait outside the call stack, so waiting doesn't block your code.

The macrotask queue (also called the task queue) holds jobs such as timer callbacks. The event loop takes one at a time.

The microtask queue holds jobs such as Promise callbacks and the code that continues after an await. It's drained completely before the next macrotask runs.

The event loop is the rule that connects them:

  1. Run the current task until the call stack is empty.
  2. Run every microtask, including any new ones added along the way.
  3. Take one macrotask and run it.
  4. Repeat.

Walking through the code

Step 1: console.log("A"). This is synchronous, so it prints right away.

Output: A

Step 2: setTimeout(..., 0). The timer is handed to the host. Its callback, which prints B, can't run until the current code has finished. It goes into the macrotask queue when the timer expires.

Step 3: Promise.resolve().then(...). The Promise is already fulfilled, so its callback, which prints C, goes straight into the microtask queue. It doesn't run yet, because .then callbacks never run synchronously.

Step 4: run(). Calling an async function runs its body synchronously until the first await. So D prints immediately.

Output: A D

Step 5: await null. This is the part that surprises people. await pauses run and queues the rest of the function, the line that prints E, as a microtask. Control goes back to the code that called run().

Step 6: console.log("F"). This is still the original script, so it prints now.

Output: A D F

Step 7: the call stack is empty. Now the microtask queue is drained, in order. C was queued first, then E.

Output: A D F C E

Step 8: one macrotask runs. The timer callback finally gets its turn.

Output: A D F C E B

What setTimeout(fn, 0) really means

It doesn't mean "run this now." It means "run this in a later task, as soon as possible." The delay is a minimum, not a guarantee.

That matters in real code. If the call stack stays busy, or a long chain of microtasks keeps adding more microtasks, the timer has to wait. In a browser, the page can't repaint during that time either, which is how a page ends up frozen.

Three things to remember

  • Synchronous code always finishes first.
  • All microtasks run before the next macrotask.
  • Code before the first await in an async function runs synchronously. Code after it doesn't.

See it run

Reading this is one thing, but it clicked for me when I could watch the queues change. I built a step-through version of this exact example, where you can watch the call stack, both queues and the console at every step:

Step through the event loop

It's part of Software Atlas, a free site I'm building with interactive explanations of how software works. It has no ads and no sign-up.

Top comments (4)

Collapse
 
li_yi_dcec96bf2647dec1b7e profile image
Mindutil •

One detail worth adding: the timer delay is a floor, not a promise. Browsers clamp nested timers to about 4 ms and throttle background tabs, so setTimeout(0) only guarantees not before the current task and its microtasks. Microtasks queued while the microtask queue is draining run in the same checkpoint, which is why an await chain that never yields to a macrotask can starve rendering.

Collapse
 
haiduc0147 profile image
Thới Hải Đức •

Good point, thanks. Yeah the 4ms clamp only kicks in after about 5 levels of nesting, so the first few timers can fire sooner. Background tabs get way worse though.

The await chain one is sneaky. Looks async, but nothing renders until it hands control back.

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

The step by step walk through the await pause is the part that clarifies the order, because it shows E becoming a microtask rather than running inline. Do you think a long chain of awaited steps could delay a timer noticeably in real code, or does the microtask drain usually finish quickly enough that setTimeout(0) stays close to immediate?

Collapse
 
haiduc0147 profile image
Thới Hải Đức •

In real code probably not. Awaits are super cheap, you'd need a crazy long chain before a timer noticed.

The problem is usually what's between them. Heavy work between already resolved awaits blocks timers and rendering, because nothing else gets a turn. Real I/O like fetch doesn't have this problem.