Field Guide · Vol. 3
One thread → Infinite illusion

The Event Loop & Async, In Full.

JavaScript has one thread. It cannot run two things at once. And yet your apps fetch data, animate, listen to clicks, and respond to timers — all "at the same time". The event loop is the choreography that makes that lie convincing.

01 · The Anatomy

JS engine + Web APIs + the loop

Most "JavaScript" you write actually depends on three separate systems. Confusing them is why beginners can't predict async output.

JS Engine (V8, SpiderMonkey) CALL STACK main() processOrder() JSON.parse() LIFO. One frame at a time. Synchronous execution. Web APIs (browser, NOT in JS engine) setTimeout fetch DOM events XHR Run on separate browser threads. JS requests; browser handles. Task Queues MICROTASK .then await MACROTASK timer click event loop delegate done → queue loop polls push frame

Fig. 1 — Three boxes, one loop. The engine runs sync code. Web APIs handle async work. Queues collect completed callbacks. The loop is the conveyor belt that moves work back to the stack.

02 · The Algorithm

What the loop actually does, tick by tick

The event loop is a tiny algorithm. Once you internalize these five steps, every "weird" async output starts to make sense.

Run synchronous code until the call stack is empty.
All top-level code, function calls, returns. The loop waits — it never interrupts running JS.
Drain ALL microtasks. Every single one.
Promise .then callbacks, await continuations, queueMicrotask. If a microtask schedules another microtask, that one also runs before moving on.
Run animation frame callbacks (if rendering this tick).
requestAnimationFrame handlers fire here, right before paint, typically at ~60Hz (every 16.6ms).
Browser does style, layout, paint, composite.
Only if needed. This is when DOM changes you made earlier actually become pixels on screen.
Pick ONE macrotask from the queue. Run it.
A timer callback, a click handler, a network response. Just one. Then back to step 1.

Fig. 2 — Microtasks always finish before the next macrotask. This is the #1 reason async output surprises people.

Microtask vs Macrotask

Microtasks

  • Sources: Promise.then, await, queueMicrotask, MutationObserver
  • Drained: ENTIRELY between each macrotask
  • Priority: Higher
  • Risk: Starvation can freeze UI

Macrotasks

  • Sources: setTimeout, I/O, UI events, postMessage
  • Drained: ONE per tick, then microtasks
  • Priority: Lower
  • Risk: >50ms = "long task" in perf tools
03 · The Classic Trace

setTimeout, Promise, sync — what prints first?

This is the canonical interview question. Walk through it once and you'll never miss it again.

console.log('1: sync start');

setTimeout(() => {
  console.log('2: timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('3: promise');
});

console.log('4: sync end');
OUTPUT 1: sync start
4: sync end
3: promise
2: timeout

Queue state, tick by tick

TICK 1 — synchronous code running
Call Stack
main() console.log
Microtask Q
.then(3)
Macrotask Q
timeout(2)
TICK 2 — stack empty, microtasks drain
Call Stack
.then(3) running
Microtask Q
empty
Macrotask Q
timeout(2)
TICK 3 — microtasks done, take one macrotask
Call Stack
timeout(2) running
Microtask Q
empty
Macrotask Q
empty

Fig. 3 — Watching the queues drain in order makes the output obvious.

04 · async / await

Syntactic sugar over microtasks

async/await looks synchronous. It isn't. Every await is a hidden microtask schedule.

async function getUser() {
  console.log('1: before await');
  const data = await fetch('/api/user');
  console.log('3: after await');
}

getUser();
console.log('2: after getUser call');

The sequential vs parallel footgun

✗ Slow (sequential)

const user = await fetchUser();
const posts = await fetchPosts();
// 200ms + 200ms = 400ms

Each await waits for the previous before starting the next.

✓ Fast (parallel)

const [user, posts] = await Promise.all([
  fetchUser(),
  fetchPosts()
]);
// max(200ms, 200ms) = 200ms

Kick off both promises first, await them together.

05 · The Footguns

How JS code freezes the UI

Because everything shares one thread — JS, layout, paint, event handling — any long-running JS blocks the whole user experience.

Long synchronous task

button.addEventListener('click', () => {
  for (let i = 0; i < 100000; i++) {
    processItem(items[i]);
  }
  // UI is frozen for the entire duration
});

Fix: chunk the work, yield to the loop

async function processAll(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    if (i % 100 === 0) {
      await new Promise(r => setTimeout(r, 0));
    }
  }
}

Microtask starvation

function loopForever() {
  Promise.resolve().then(loopForever);
}
loopForever();  // browser tab is dead

Each microtask schedules another. The loop never drains, never paints, never picks up macrotasks. Worse than while(true) because the developer thinks "but it's async!"

The interview answer

"JavaScript is single-threaded. The event loop coordinates the call stack, Web APIs (which run on browser threads), and two queues — microtasks and macrotasks. After every sync block, it drains ALL microtasks first, then runs ONE macrotask, then renders if needed. Promise.then and await queue microtasks; setTimeout and DOM events queue macrotasks. That priority is why Promise.resolve().then(...) always wins against setTimeout(..., 0)."

Five gotchas

  1. Microtasks beat macrotasks — every time.
  2. Sequential awaits are slow — use Promise.all for independent work.
  3. Long sync tasks freeze the UI — yield via await new Promise(r => setTimeout(r, 0)).
  4. Microtask infinite loops kill the tab silently — looks healthy from outside.
  5. requestAnimationFrame is its own queue — fires right before paint, capped at refresh rate.
Event loop & async field guide · Frontend Field Guides

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

Keep reading