Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

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

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");

Take a guess before you scroll.

.

.

.

The answer is:

A D F C E B

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.