JavaScript is single-threaded — it can only run one piece of code at a time. Yet it handles network requests, timers, and user events without blocking. The event loop is the mechanism that makes this possible. Once you understand it, async code ordering stops being mysterious.
Quick-reference table
| Concept | What it is | Priority |
|---|---|---|
| Call stack | Tracks currently executing functions (LIFO) | Runs first |
| Web APIs | Browser/Node environment (setTimeout, fetch, DOM) | Offloads async work |
| Microtask queue | Promise callbacks, queueMicrotask, MutationObserver |
Drains after each task |
| Task queue (macrotask) | setTimeout, setInterval, I/O callbacks, UI events |
One task per loop tick |
| Event loop | Moves tasks from queues onto the empty call stack | Orchestrates all of the above |
The four components
1. Call stack
The call stack tracks function calls. When you call a function, it's pushed onto the stack. When it returns, it's popped off. JavaScript can only execute what's on top of the stack.
function greet(name) {
return `Hello, ${name}`;
}
function main() {
const msg = greet("Alice"); // greet pushed, then popped
console.log(msg); // console.log pushed, then popped
}
main(); // main pushed onto stack
Stack state (bottom → top):
main()
greet("Alice") ← executes, returns, popped
main()
console.log(msg) ← executes, returns, popped
main() ← returns, popped
(empty)
2. Web APIs / Node APIs
When you call setTimeout, fetch, addEventListener, or any async API, the browser (or Node) handles the wait outside the call stack. Your JavaScript thread is free to do other work.
console.log("A");
setTimeout(() => {
console.log("B"); // moved to Web APIs, timer starts
}, 1000);
console.log("C");
// Output: A, C, B
setTimeout is not JavaScript — it's a browser API. The callback is held by the browser until the timer fires, then it's placed in the task queue.
3. Task queue (macrotask queue)
When a Web API is done (timer expired, fetch completed, user clicked), it places the callback in the task queue. The event loop picks tasks from this queue one at a time — but only when the call stack is empty.
Macrotask sources:
setTimeout/setInterval- I/O callbacks (Node.js file reads, network)
- UI events (click, keypress)
MessageChannelsetImmediate(Node.js only)
4. Microtask queue
Microtasks have higher priority than macrotasks. After every task completes, the event loop drains the entire microtask queue before moving to the next task.
Microtask sources:
Promise.then()/.catch()/.finally()callbacksasync/awaitcontinuations (they're sugar over Promises)queueMicrotask(fn)MutationObservercallbacks
How the event loop works
┌─────────────────────────────────────────────────┐
│ JavaScript Engine │
│ │
│ ┌──────────────┐ ┌───────────────────────┐ │
│ │ Call Stack │ │ Web APIs │ │
│ │ │ │ setTimeout / fetch │ │
│ │ fn3() │ │ DOM / Node I/O │ │
│ │ fn2() │ └────────────┬──────────┘ │
│ │ fn1() │ │ │
│ └──────┬───────┘ │ │
│ │ (when empty) │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Microtask Queue (Promise callbacks) │ │
│ │ [task1, task2, task3, ...] │ │
│ └──────────────────┬───────────────────────┘ │
│ │ (drain ALL first) │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Task Queue (setTimeout, events, I/O) │ │
│ │ [task4, task5, ...] │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
One loop tick:
- Pick one macrotask from the task queue (or run script)
- Execute it (call stack empties)
- Drain all microtasks (run every pending microtask, including any new ones added during this step)
- Render update (browser only)
- Go back to step 1
Execution order examples
setTimeout vs Promise
console.log("1");
setTimeout(() => console.log("2"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4");
// Output: 1, 4, 3, 2
Why? The main script runs synchronously (1, 4). After the script (a macrotask), all microtasks drain (3). Then the next macrotask runs (2).
Nested microtasks
Microtasks added during microtask processing are run before the next macrotask:
Promise.resolve()
.then(() => {
console.log("microtask 1");
Promise.resolve().then(() => console.log("microtask 2 (nested)"));
})
.then(() => console.log("microtask 3"));
setTimeout(() => console.log("macrotask"), 0);
// Output:
// microtask 1
// microtask 2 (nested)
// microtask 3
// macrotask
async/await order
await pauses the async function and resumes it as a microtask:
async function fetchData() {
console.log("A");
await Promise.resolve(); // pauses here
console.log("B"); // runs as microtask
}
console.log("start");
fetchData();
console.log("end");
// Output: start, A, end, B
fetchData() runs synchronously until the first await, then the rest is scheduled as a microtask. The outer code finishes ("end") before the continuation runs ("B").
Multiple awaits
async function run() {
console.log("1");
await delay(0); // each await = one microtask checkpoint
console.log("2");
await delay(0);
console.log("3");
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
run();
console.log("4");
// Output: 1, 4, 2, 3
Each await delay(0) yields to a setTimeout, so "2" and "3" come after the synchronous "4", delayed by separate event loop ticks.
Real-world patterns
Don't block the event loop
Long synchronous operations block the entire UI and all async callbacks:
// BAD — blocks for ~1 second on large arrays
function slowSort(arr) {
return arr.sort((a, b) => a - b); // synchronous, can't interrupt
}
// BETTER — yield to the event loop between chunks
async function chunkedSort(arr, chunkSize = 1000) {
const sorted = [...arr];
for (let i = 0; i < sorted.length; i += chunkSize) {
sorted.splice(i, chunkSize, ...sorted.slice(i, i + chunkSize).sort());
await new Promise(resolve => setTimeout(resolve, 0)); // yield
}
return sorted;
}
Scheduling priorities
// Highest priority — runs before next render
queueMicrotask(() => console.log("microtask"));
// Low priority — one macrotask slot
setTimeout(() => console.log("timeout"), 0);
// requestAnimationFrame — before next paint (browser)
requestAnimationFrame(() => console.log("rAF"));
// Order: microtask → rAF → timeout (implementation varies)
Avoid microtask starvation
If microtasks keep adding more microtasks, macrotasks (and rendering) never run:
// BAD — infinite microtask loop starves everything else
function loop() {
Promise.resolve().then(loop);
}
loop(); // locks up the browser
// GOOD — use setTimeout to yield macrotask slot
function loop() {
setTimeout(loop, 0);
}
Node.js specifics
Node.js has its own event loop phases (powered by libuv):
| Phase | Runs |
|---|---|
| timers | setTimeout / setInterval callbacks |
| pending callbacks | I/O errors from previous iteration |
| idle, prepare | Internal use |
| poll | Retrieve new I/O events; execute callbacks |
| check | setImmediate callbacks |
| close callbacks | e.g., socket.on("close", ...) |
Between each phase, Node drains microtasks (Promises) and process.nextTick callbacks. process.nextTick has higher priority than Promises:
Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("nextTick"));
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
// Output:
// nextTick
// Promise
// setTimeout (usually before setImmediate, but not guaranteed)
// setImmediate
6 common mistakes
| Mistake | Problem | Fix |
|---|---|---|
setTimeout(fn, 0) assumed instant |
Still goes through macrotask queue — microtasks run first | Use queueMicrotask for truly immediate async |
| Blocking loop inside async function | Synchronous code still blocks even inside async |
Break work into chunks with await |
Assuming await pauses the whole program |
Only pauses the current async function | Other microtasks still interleave |
| Infinite Promise chain as "loop" | Starves rendering and macrotasks | Use setTimeout for recurring work |
process.nextTick abuse in Node |
Starves I/O if overused | Reserve for rare correction cases |
| Unhandled rejection lost silently | Promise error with no .catch — no throw, no log |
Always .catch() or try/catch around await |
Quick reference
// Check execution order mentally:
// 1. Synchronous code runs first (call stack)
// 2. Microtasks drain (Promises, queueMicrotask)
// 3. One macrotask runs (setTimeout, events, I/O)
// 4. Microtasks drain again
// 5. Repeat
// Useful tools for yielding
await Promise.resolve(); // yield to microtask queue
await new Promise(r => setTimeout(r, 0)); // yield to macrotask queue
queueMicrotask(fn); // schedule microtask explicitly
requestAnimationFrame(fn); // schedule before next paint (browser)
setImmediate(fn); // schedule after poll phase (Node.js)
process.nextTick(fn); // schedule before Promises (Node.js)
FAQ
Why is JavaScript single-threaded? When JavaScript was created for browsers in 1995, multi-threaded DOM access would have created race conditions that were very hard to manage. Single-threading simplifies the programming model. Web Workers add parallelism for CPU-heavy tasks without shared DOM access.
What's the difference between the task queue and microtask queue?
Microtasks (Promises) drain completely after each task before any new macrotask starts. This means if you queue 100 microtasks, all 100 run before the next setTimeout callback. The macrotask queue processes one task per event loop tick.
Does async/await use threads?
No. async/await is syntax sugar over Promises and the event loop. An await suspends the current function, returns control to the call stack, and resumes when the awaited Promise settles — all in one thread.
Why does setTimeout(fn, 0) not mean "run immediately"?
setTimeout is a Web API that always moves the callback to the macrotask queue, even with a 0ms delay. All pending microtasks run first, plus the browser may impose a minimum delay (~4ms in nested timeouts per the HTML spec).
What is requestAnimationFrame and where does it fit?
requestAnimationFrame is a browser API that schedules a callback before the browser's next repaint. It runs after microtasks but before most macrotasks, making it ideal for animation code that needs to sync with the display refresh rate (typically 60fps).
How can I visualize the event loop in action?
Use the Loupe tool (browser-based) or the Performance tab in Chrome DevTools. In Node.js, --inspect mode with Chrome DevTools shows async task timing.