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 (8)

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.

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

That distinction is useful, because it shifts the blame from the await chain to the work sitting between awaits. When you find such a blocking stretch in practice, do you usually split it into smaller chunks, move it off the main thread, or first measure how long it actually delays rendering?

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

I measure first, I've guessed wrong too many times. Performance tab in DevTools shows it pretty fast.

If it's a loop I break it up with setTimeout. If it's just heavy work I throw it in a worker. More awaits won't fix it btw, the browser still doesn't get a turn.

Thread Thread
 
sgaggjhkjh profile image
Arjun Sharma •

Measuring first makes sense, and your loop versus heavy work split is a useful rule of thumb. When you break a loop with setTimeout, do you tune the chunk size by the measured frame budget, or do you start with a fixed chunk and adjust only if the interface still stutters?

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

Neither really, I go by time instead of item count. Keep going until ~5ms has passed, then yield. Items rarely take the same time, so any fixed chunk size ends up wrong for some of them.

let start = performance.now();
for (const item of items) {
  process(item);
  if (performance.now() - start > 5) {
    await new Promise(r => setTimeout(r));
    start = performance.now();
  }
}
Enter fullscreen mode Exit fullscreen mode

Where it's supported I use scheduler.yield() instead of setTimeout. Your task gets to continue before other queued work instead of going to the back of the line, and you skip setTimeout's 4ms minimum delay after a few nested calls.

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.