Here is a classic interview question. Before reading on, guess what this prints:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
queueMicrotask(() => console.log("D"));
console.log("E");Quick check
What is the output order?
If you got A E C D B, you already have a feel for the event loop. If not, you are in great company — and by the end of this article you will be able to predict the order of any async code instead of guessing.
One thread, many jobs
JavaScript in the browser runs on a single main thread. That thread executes your code, handles clicks, and also does the page's layout and painting. It can do exactly one thing at a time.
So how can a page fetch data, run a timer and respond to a click “at the same time”? It cheats, elegantly: slow work is handed to the browser (which does have other threads), and when that work is done, a callback is placed in a queue. The event loop is the tiny scheduler that decides which queued callback runs next.
The four pieces
- Call stack — the code running right now. Functions are pushed when called and popped when they return.
- Web APIs — browser features like timers,
fetchand DOM events. They work outside your thread and queue a callback when done. - Microtask queue — high-priority callbacks:
Promise.then,queueMicrotask, and the continuation after everyawait. - Task queue (macrotasks) —
setTimeout,setInterval, clicks, network and message events.
Watch it run
Press play and follow the quiz code line by line. Watch where each callback goes — and notice the moment the stack becomes empty.
quiz.js
- 1console.log("A");
- 2setTimeout(() => console.log("B"), 0);
- 3Promise.resolve().then(() => console.log("C"));
- 4queueMicrotask(() => console.log("D"));
- 5console.log("E");
console
Call stack
runs now (LIFO)
Web APIs
timers, fetch, DOM
Microtasks
promises · drained fully
Task queue
timers · one per loop
The whole script is pushed onto the call stack as one task. Nothing else can run until it finishes.
Microtasks vs tasks: the one rule to remember
After each task, the event loop empties the whole microtask queue — then it may render — then it takes exactly one task.
That single rule explains almost every “weird” ordering you will meet:
- Promises always beat timers scheduled at the same moment.
- A microtask that schedules another microtask runs in the same round — the queue keeps draining.
- Because rendering waits for microtasks, an endless chain of promises can freeze the page just like an infinite loop.
Where async/await fits
async/await is syntax on top of promises. Everything before the first await runs synchronously; everything after it is scheduled as a microtask once the awaited promise settles.
async function load() {
console.log("1 — sync, runs immediately");
await null;
console.log("3 — a microtask");
}
load();
console.log("2 — still the same task");Quick check
An async function calls `await fetch(url)`. While the request is in flight, is the main thread blocked?
Bugs the event loop explains
1. “My spinner never shows”
You set loading = true, then run a heavy synchronous loop. The browser never gets a chance to paint until the loop ends — so the spinner appears and disappears in the same frame. Fix: split the work into chunks with setTimeout / scheduler.yield(), or move it to a Web Worker.
2. “Clicks feel laggy”
A click is a task. If a long task is on the stack, the click waits. Google measures exactly this delay with the INP metric — see Core Web Vitals explained.
3. “State is stale in my callback”
A callback runs later, against whatever state exists then. Understanding that the callback sits in a queue — not inside your function — makes closures and stale-state bugs far easier to reason about.

