The event loop, visualized

If you've spent any time interviewing for frontend roles, you've probably faced some variation of this classic code snippet:

console.log('A');

setTimeout(() => {
  console.log('B');
}, 0);

Promise.resolve()
  .then(() => console.log('C'))
  .then(() => console.log('D'));

console.log('E');

Most devs can spit out the answer without thinking: A, E, C, D, B. But there's a massive difference between memorizing the output and actually visualizing why the browser executes it in that order.

That gap is usually what bites you when a UI element randomly flickers, an input feels laggy, or a setTimeout(..., 0) somehow takes 50ms to fire.

Instead of jumping straight into a wall of text, use the visualizer below to step through exactly how the browser handles this code:

Let's break down exactly what's happening under the hood.

The Event Loop Recipe

At its core, the browser's main thread is just a single worker running a continuous loop. Every single turn of that loop follows a strict, predictable routine:

  1. Grab one task from the task queue and run it until it's completely done.
  2. Drain the entire microtask queue. If running a microtask queues another microtask, run that one too. Don't stop until the queue is empty.
  3. Check if it's time to render. If the browser needs to update the screen (usually aiming for a stable 60Hz or 120Hz refresh rate), handle the style, layout, and paint steps right here.
  4. Go back to step 1.

Almost every weird scheduling bug comes down to confusing which bucket your code ended up in.

Tasks run to completion

When JavaScript runs a block of script, that entire block is treated as a single task. The engine will not pause mid-function to let a click handler run or paint a frame.

This "run-to-completion" rule is great for predictability (meaning your variables won't magically change out from under you mid-execution), but it's incredibly easy to abuse. If a single function takes 200ms to finish, the entire browser tab freezes for those 200ms. The event loop isn't slow; your code is just hogging the microphone.

setTimeout is a task (and 0 is a lie)

Writing setTimeout(fn, 0) doesn't mean "execute this immediately." It means "toss this over to the browser's timer thread, and the moment it fires, put it at the very back of the task queue."

Before that callback ever gets a turn, the engine has to finish the current script, completely clear out the microtask queue, and potentially render the page. So "0ms" really means "at the absolute earliest opportunity after everything else settles."

Note: The HTML spec actually forces a minimum 4ms delay once you nest timers a few levels deep, and background tabs get throttled even harder. The browser treats the timing as a minimum guarantee, not a firm appointment.

Microtasks: The line-cutters

Promises, await continuations, and queueMicrotask bypass the standard task queue entirely. They go straight to the microtask queue, which gets special VIP treatment. The engine refuses to move on to rendering or the next major task until this queue is completely bone-dry.

This creates two massive quirks:

  • Chained promises will always beat a pending setTimeout. In our example, when the first promise resolves, it immediately queues the next .then() callback while the microtask queue is still draining. Because the engine won't stop until the queue is empty, D logs before the timeout's B even has a chance.
  • You can easily freeze the browser with microtasks. If a microtask keeps spawning another microtask, the event loop gets stuck in an endless loop:
function infinite() {
  Promise.resolve().then(infinite);
}
infinite(); // Goodbye, browser tab.

Interestingly, doing the exact same thing with setTimeout won't crash the tab. Because each timeout is treated as a brand-new task, the browser can easily slide a paint job or a click event in between iterations.

Rendering happens inside the loop

A common misconception is that the browser updates the UI in the background while your code runs. In reality, styling, layout, and painting happen right inside the event loop itself, right after the microtasks drain.

This explains a classic CSS transition bug:

el.style.transform = 'translateX(0)';
el.style.transform = 'translateX(300px)'; // The element just snaps to 300px. Why?

Because both style changes happen within the exact same task, the browser doesn't render anything in between them. By the time the event loop hits the render phase, it only sees the final value of 300px.

If you want the browser to catch the intermediate state, you have to let the task finish and allow a rendering opportunity to happen. This is where requestAnimationFrame comes in. It runs as part of the render step, right before layout and paint happen. That's why rAF is perfect for animations, while setTimeout(fn, 16) is just a guessing game.

The Mental Model

If you want to keep it simple, just memorize the rhythm of the machine:

Execute one task → Drain all microtasks → Render if needed → Repeat.

  • Task Queue: setTimeout, DOM events, postMessage (Patient: one at a time per loop turn).
  • Microtask Queue: Promises, await, queueMicrotask (Aggressive: drains completely before moving on).
  • Render Phase: requestAnimationFrame, style recalculations, layout, paint (Periodic: once per frame).

Interviewers love this puzzle because it tests whether you actually understand the engine's heartbeat. Once you see the steps visually, you aren't guessing the log order anymore—you're just reading the blueprint.


Further reading: Jake Archibald's Tasks, microtasks, queues and schedules remains the canonical deep dive, and the HTML spec's event loop processing model is surprisingly readable.

The visualizer above is a hand-written web component built with no framework, no dependencies, and lazy-loaded when you scroll near it. Like everything on this site.