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.
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.
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.
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.
.then callbacks, await continuations, queueMicrotask. If a microtask schedules another microtask, that one also runs before moving on.requestAnimationFrame handlers fire here, right before paint, typically at ~60Hz (every 16.6ms).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
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');
4: sync end
3: promise
2: timeout
Queue state, tick by tick
Fig. 3 — Watching the queues drain in order makes the output obvious.
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.
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
- Microtasks beat macrotasks — every time.
- Sequential
awaits are slow — usePromise.allfor independent work. - Long sync tasks freeze the UI — yield via
await new Promise(r => setTimeout(r, 0)). - Microtask infinite loops kill the tab silently — looks healthy from outside.
- requestAnimationFrame is its own queue — fires right before paint, capped at refresh rate.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…