The Event Loop
JavaScript is Single-Threaded
JavaScript runs on a single thread. It can do one thing at a time. If JavaScript blocked every time it waited for a network request, the browser would freeze. The event loop makes asynchronous code possible on a single thread.
The Call Stack
The call stack tracks what function is currently executing. Functions are pushed on call and popped on return.
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function printSquare(n) {
const result = square(n);
console.log(result);
}
printSquare(4);
Stack: printSquare → square → multiply → (returns) → (returns) → console.log → (returns)
When the stack is busy, nothing else happens. The browser cannot render, handle clicks, or respond to input.
Web APIs & The Task Queue
The browser provides APIs that run outside the main thread: setTimeout, fetch, DOM events. When the work is done, the browser places a callback into the task queue. The event loop processes it only when the call stack is empty.
console.log("Start");
setTimeout(() => console.log("Timeout"), 1000);
console.log("End");
Start
End
Timeout
setTimeout(fn, 0) Does Not Run Immediately
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
1
3
2
Even with a delay of 0, the callback goes to the task queue. It runs only after all synchronous code finishes.
The Microtask Queue
Microtasks have higher priority than macrotasks. The event loop drains all microtasks before processing the next macrotask.
Microtask sources: Promise.then/.catch/.finally, queueMicrotask(), MutationObserver
Macrotask sources: setTimeout, setInterval, DOM events, requestAnimationFrame
console.log("1");
setTimeout(() => console.log("2 — macrotask"), 0);
Promise.resolve().then(() => console.log("3 — microtask"));
console.log("4");
1
4
3 — microtask
2 — macrotask
Microtasks Can Starve Macrotasks
The event loop processes all pending microtasks before moving on. A microtask that queues another microtask delays everything else.
Promise.resolve()
.then(() => { console.log("Microtask 1"); return Promise.resolve(); })
.then(() => { console.log("Microtask 2"); return Promise.resolve(); })
.then(() => console.log("Microtask 3"));
setTimeout(() => console.log("Timeout"), 0);
// All three microtasks run before the timeout
The Event Loop Algorithm
- Execute all synchronous code on the call stack.
- When the stack is empty, drain the entire microtask queue.
- Optionally render (roughly every 16ms).
- Take one macrotask from the task queue and execute it.
- Go to step 2.
Real-World Consequences
Predicting Execution Order
console.log("A");
setTimeout(() => console.log("B"), 0);
setTimeout(() => console.log("C"), 0);
Promise.resolve().then(() => console.log("D"));
Promise.resolve().then(() => console.log("E"));
console.log("F");
A
F
D
E
B
C
Synchronous first, then microtasks, then macrotasks in order.
UI Freezing
Long synchronous code blocks everything. Break heavy work into chunks.
// Bad: blocks the main thread
function processAll(items) {
items.forEach(item => heavyComputation(item));
}
// Better: process in chunks, yielding to the event loop
function processInChunks(items, chunkSize, callback) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (let i = index; i < end; i++) callback(items[i]);
index = end;
if (index < items.length) setTimeout(processChunk, 0);
}
processChunk();
}
Race Conditions
Even on a single thread, async code creates ordering issues.
// Bug: whichever fetch finishes last wins
let latestData = null;
async function fetchData(url) {
const response = await fetch(url);
latestData = await response.json();
}
fetchData("/api/page/1");
fetchData("/api/page/2"); // latestData could be either
// Fix: track which request is current
let currentRequestId = 0;
async function fetchData(url) {
const requestId = ++currentRequestId;
const response = await fetch(url);
const data = await response.json();
if (requestId === currentRequestId) latestData = data;
}
async/await Ordering
Code after await is a microtask, not synchronous.
async function example() {
console.log("A");
await Promise.resolve();
console.log("B"); // microtask
}
example();
console.log("C");
A
C
B
Common Pitfalls
Assuming setTimeout(fn, 0) runs immediately. It runs after all synchronous code and all microtasks. The minimum delay is often 4ms in browsers.
Starving the task queue with microtasks. A microtask that queues another microtask in an infinite loop freezes the browser permanently.
// This freezes the browser
function bad() { Promise.resolve().then(bad); }
Blocking the main thread with synchronous computation. JSON parsing, large array operations, or complex calculations block rendering. Move heavy work to a Web Worker or break it into chunks.
Assuming network requests return in order. Two fetch calls may resolve in any order. Track request IDs or use AbortController.
Key Takeaways
- JavaScript is single-threaded. The event loop makes asynchronous code possible.
- The call stack executes synchronous code. When empty, the event loop checks the queues.
- Microtasks (Promises,
queueMicrotask) always run before macrotasks (setTimeout, events). setTimeout(fn, 0)means "run after everything currently queued," not "run now."- Long synchronous operations freeze the UI. Break heavy work into chunks or use Web Workers.
- Async code on a single thread still creates race conditions. Track request IDs or use
AbortController.