Event Loop Simulator

setTimeout(fn, 0) and Promise.resolve().then(fn) queued at the same time. Which runs first? The Promise. Every time. Add tasks below, step through, and watch the call stack + microtask + macrotask queues drain in the order the spec says they must.

frame
0
queued
0
done
0

Call stack 0
— empty —
Microtask queue 0
— empty —
Macrotask queue 0
— empty —
rAF queue 0
— empty —
Execution log · 0 tasks completed
— nothing executed yet —

What you're looking at

The four columns are the places the runtime pulls work from: the call stack for synchronous code, the microtask queue for Promise callbacks, the macrotask queue for setTimeout and the like, and the rAF queue drained just before a paint. The execution log records the order things actually ran, and the active queue lights up as it drains. Each Step runs one macrotask and then empties the whole microtask queue.

Hit Seed then Auto and read the log: the synchronous lines finish first, then the Promise, then the setTimeout — even though setTimeout was queued before the Promise. Add three Promises and Step once: all three drain in the same tick, because the rule for microtasks is drain to empty before anything else runs. The point that should land is that a Promise never yields to the browser — only a macrotask boundary does — so a chain that keeps queuing microtasks can starve paint entirely. Add an rAF and step to watch it wait for a render frame instead of running straight away.


JavaScript is single-threaded — but it doesn't feel that way

Asynchrony without threads, via a queue and a loop.

JavaScript has one thread. One call stack. At any instant, exactly one function is executing. And yet your browser can scroll smoothly, fetch data over the network, fire timers, respond to clicks, and animate at 60 frames per second — all without blocking on each other. How?

The trick is the event loop: a queue of tasks that the runtime picks one at a time. When you call setTimeout, you're not actually scheduling a thread; you're appending a callback to the macrotask queue. When the current synchronous code finishes and the call stack empties, the event loop picks the next item from the queue and runs it. Repeat forever.

There are two queues (well, three, if you count rAF). The microtask queue holds Promise callbacks and queueMicrotask items. The macrotask queue holds setTimeout, setInterval, I/O completion, and UI events. The distinction matters because they don't run in insertion order — microtasks always beat macrotasks in any given tick.


Why setTimeout(fn, 0) doesn't actually run immediately

The classic interview question, settled.

Run this in any browser console:

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

Output: 1, 4, 3, 2. Even though setTimeout(…, 0) says "run this in zero milliseconds", the Promise still runs first. Why?

The spec orders things very precisely. After each macrotask (including the initial script execution), the event loop drains the entire microtask queue before picking the next macrotask. Promises fire microtasks; setTimeout fires macrotasks. So in our example: 1 and 4 run as synchronous code (the "initial macrotask"); then the microtask queue drains, running the Promise — 3; then the next macrotask runs — the setTimeout — printing 2.

This matters in practice. If you schedule a flurry of Promise callbacks, they all run before the browser gets to paint or handle the next click. Promises are not "yield to the browser"; they're "run after this synchronous batch but before anything else". For yielding to the browser, you want a macrotask (setTimeout(…, 0)) or rAF.


How a Promise chain can freeze your tab

The microtask queue drains until empty — what if it never empties?

Try this in a console (carefully — it will hang the tab):

function loop() {
  return Promise.resolve().then(loop);
}
loop();

Every Promise.then queues another microtask. The event loop's rule for microtasks is "drain to empty before doing anything else" — including paint, click handlers, and the next setTimeout. So this loop never lets go. The browser can't repaint, can't respond to your click on the close-tab button, can't run any other task.

Compare to the macrotask version:

function loop() {
  setTimeout(loop, 0);
}
loop();

This also runs forever, but the browser stays responsive. Each iteration is a separate macrotask, so the microtask queue drains (it's empty), rAF callbacks run, paint happens — then the next setTimeout macrotask is picked up. The browser gets its breath between iterations.

The lesson generalises beyond infinite loops. Any time you do CPU-bound work via Promise.then chains, recursive queueMicrotask, or tight await loops, you're starving the render pipeline. If the work needs to yield, break it into macrotasks with setTimeout(…, 0), or use the modern scheduler.yield() from the JavaScript scheduler API.


requestAnimationFrame is not a macrotask

It has its own queue, drained right before paint.

A common misconception: requestAnimationFrame is just another way to schedule a macrotask. It isn't. rAF callbacks live in a separate queue that the browser only drains when it's about to paint a frame — typically every 16.6ms on a 60Hz display, less often if the browser decides to skip frames under load.

The order each tick, when a render is due:

1. Pop one macrotask, run it
2. Drain the entire microtask queue
3. If it's time to render:
   a. Drain the rAF queue
   b. Run style recalc
   c. Run layout
   d. Paint

This ordering has subtle consequences. A Promise queued from inside an rAF callback runs before the next paint, not after it — because step 2 of the next tick drains the microtask queue. If you're writing layout-thrashing code, batching DOM reads with rAF + a Promise can accidentally re-trigger layout inside the same frame.

Use rAF for: visual updates, canvas animations, scroll-synced transforms, anything that must happen at the next paint. Don't use rAF for: "run this later" — that's setTimeout. Don't use rAF for: "yield to the browser" — that's a macrotask boundary, not a render boundary.

One more gotcha: requestAnimationFrame callbacks don't fire in background tabs (the browser throttles to once per second or less). If you're driving animation state from rAF, your math will be wrong when the user returns to the tab. Track elapsed time from the rAF timestamp argument, not from a counter.


Node.js has six phases, not two queues

Same event-loop idea, very different machinery.

The browser event loop has one macrotask queue plus the microtask queue. Node.js, running on libuv, has six phases that drain in order on every tick:

┌─────────────────────────┐
│ 1. timers               │  setTimeout, setInterval callbacks
├─────────────────────────┤
│ 2. pending callbacks    │  TCP error retries, etc.
├─────────────────────────┤
│ 3. idle, prepare        │  internal libuv bookkeeping
├─────────────────────────┤
│ 4. poll                 │  I/O callbacks (fs, net, http)
├─────────────────────────┤
│ 5. check                │  setImmediate callbacks
├─────────────────────────┤
│ 6. close                │  socket.on('close'), etc.
└─────────────────────────┘

Between every callback in every phase, Node drains two queues: the nextTick queue (process.nextTick) and the microtask queue (Promises). nextTick beats microtasks. Microtasks beat the next phase callback.

This is why setImmediate and setTimeout(fn, 0) behave differently in Node:

// Inside an I/O callback (e.g. fs.readFile):
//   setImmediate ALWAYS runs before setTimeout(0)
//   because the next phase after poll is check.

// Outside an I/O callback (e.g. top-level script):
//   the order is non-deterministic — depends on
//   how long the script took before the timers
//   phase rolls over.

And process.nextTick:

process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
setImmediate(() => console.log("setImmediate"));
setTimeout(() => console.log("setTimeout"), 0);

// Output (always):
//   nextTick
//   promise
//   (then either setTimeout or setImmediate)

The practical takeaway: server-side JS doesn't behave like browser JS, even though both use Promises and timers. If you're writing code that runs in both environments, don't rely on the precise ordering of setTimeout(0) vs setImmediate, and remember that process.nextTick doesn't exist in browsers — use queueMicrotask instead.


Long tasks, INP, and the event loop in production

What the browser tells you when the loop gets stuck.

Two Web Vitals metrics are essentially event-loop measurements in disguise. Long Tasks flags any single task above 50ms — meaning the main thread was occupied for that long without yielding. INP (Interaction to Next Paint), which replaced FID in 2024, measures the worst-case latency from a user input event to the next rendered frame.

INP is degraded by anything that holds the macrotask queue:

A long synchronous function inside the input handler. A microtask chain that drains the queue before the browser can paint. A heavy requestAnimationFrame callback that runs layout work. A third-party script blocking the main thread. A WASM call that doesn't yield.

The fix surface is small and predictable. Break long synchronous functions into chunks that yield with setTimeout(0) or scheduler.yield(). Move heavy compute to a Web Worker — truly off the main thread, not just "asynchronous". Avoid deep Promise chains in the input-handler path. Defer non-critical work with requestIdleCallback (browser) or setImmediate (Node).

A useful pattern is the yield-to-main wrapper:

async function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0));
}

async function processChunks(items) {
  for (let i = 0; i < items.length; i++) {
    process(items[i]);
    if (i % 100 === 0) await yieldToMain();
  }
}

Every 100 items, the function yields control: the browser gets to run a macrotask boundary, drain microtasks, possibly paint. INP stays low. The total wall-clock time barely changes.


How to debug an event-loop bug

Three tools, in order of usefulness.

The Performance panel shows the main thread itself, task by task. Record while reproducing the bug, then look at the main-thread track. Red triangles in the top-right corner of a task mean it's flagged as a long task. The flame chart inside each task shows the call hierarchy. The yellow "Task" bars are what the event loop is doing. The narrow tracks below show Layout, Paint, and Composite — when they're missing, you have your culprit: nothing is reaching the render path.

Manual timestamps are often faster for narrow questions. Drop console.log(performance.now(), 'label') at suspected scheduling points. The real-time order in the console answers most "did this run before that?" questions without firing up the profiler.

The Promise.then trick isolates microtask ordering. If you suspect microtask draining is reordering something, wrap the suspect call:

Promise.resolve().then(() => suspect());

If the bug changes, microtasks are causing it. If nothing changes, microtasks are not the cause — the bug is elsewhere (macrotask ordering, rAF, or a race condition with the network).

A bug pattern that shows up constantly: someone writes await fetch() inside a tight for loop. Each await suspends the function and queues the resume as a microtask. The microtask queue drains immediately; the next iteration starts before the browser paints; the UI freezes. The fix is to batch — fetch a chunk, await it, yield to the macrotask queue with setTimeout(0), fetch the next chunk.


A model that fits in your head

Five sentences that explain most of what surprises you.

Synchronous code runs to completion; nothing else runs while the call stack is non-empty. After each macrotask, the entire microtask queue drains — including microtasks added during draining. setTimeout(fn, 0) doesn't mean "now", it means "the next macrotask, after this script and all microtasks finish". requestAnimationFrame is for paint, not for "later" — it has its own queue, drained right before render. Node.js has six phases instead of one macrotask queue, and process.nextTick beats Promises.

Most event-loop surprises come from forgetting one of those five rules. If something runs in an order you didn't expect, start by asking: which queue is it in? When does that queue drain? Is there a microtask drain between me and the next thing I'm waiting for?

From here, the next places to go are the event loops deep dive for the spec-level details, Go's scheduler for a contrast with a multi-threaded runtime, and the thread pools page for how I/O actually gets done while JavaScript stays single-threaded.


Found this useful?