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.
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. PaintThis 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.