The Event Loop
The event loop is the mechanism that allows JavaScript to be non-blocking despite running on a single thread. Understand the call stack, task queues, and microtask queue.
JavaScript is Single-Threaded
JavaScript executes code on a single thread โ one operation at a time. There's no parallel execution (without Web Workers). Yet it handles thousands of concurrent operations like network requests and user events. How?
The answer: the event loop.
The Call Stack
The call stack is a LIFO (last-in, first-out) data structure that tracks which function is currently executing:
function c() {
console.log("c");
}
function b() {
c();
console.log("b");
}
function a() {
b();
console.log("a");
}
a();
Call stack states:
a()pushedb()pushedc()pushedc()returns (popped), prints "c"b()returns (popped), prints "b"a()returns (popped), prints "a"
Stack overflow happens when functions recurse too deeply โ the stack runs out of space.
The Web APIs
When async operations are called (setTimeout, fetch, etc.), they're handed off to Web APIs (browser-provided environments outside the JS engine):
console.log("1");
setTimeout(() => {
console.log("3: timeout callback");
}, 0); // even 0ms doesn't run immediately!
console.log("2");
// Output: 1, 2, 3
The timeout callback doesn't run immediately because:
console.log("1")runssetTimeoutis handed to the Web API timerconsole.log("2")runs- The main thread is free โ the timer callback enters the task queue
- The event loop picks it up and runs it
Task Queue (Macrotask Queue)
The task queue holds callbacks from:
setTimeout/setInterval- DOM events (click, scroll, keypress)
- Network responses (XHR)
MessageChannel
setTimeout(() => console.log("timeout 1"), 0);
setTimeout(() => console.log("timeout 2"), 0);
// Synchronous code runs first:
console.log("sync");
// Output: sync โ timeout 1 โ timeout 2
Microtask Queue
The microtask queue has higher priority than the task queue. It's processed completely after each task (before the next task runs):
Microtasks come from:
- Promise callbacks (
.then(),.catch(),.finally()) queueMicrotask()MutationObserver
console.log("1: sync");
setTimeout(() => console.log("4: timeout"), 0);
Promise.resolve()
.then(() => console.log("3: promise"));
console.log("2: sync");
// Output: 1, 2, 3, 4
// Microtasks (promises) run before macrotasks (setTimeout)!
The Event Loop Algorithm
The event loop follows this cycle:
while (true) {
1. Pick one task from the task queue (if any)
2. Execute it (runs synchronous code + Web API calls)
3. Process ALL microtasks in the microtask queue
(including any microtasks queued during this step)
4. If needed, render/update the UI
5. Go back to step 1
}
Key insight: The microtask queue is drained completely before moving to the next task.
Visualizing the Order
console.log("--- Start ---");
setTimeout(() => console.log("setTimeout 1"), 0);
setTimeout(() => console.log("setTimeout 2"), 0);
Promise.resolve()
.then(() => {
console.log("Promise 1");
return Promise.resolve();
})
.then(() => console.log("Promise 2"));
queueMicrotask(() => console.log("Microtask"));
console.log("--- End ---");
// Output:
// --- Start ---
// --- End ---
// Promise 1 โ microtask
// Microtask โ microtask
// Promise 2 โ microtask (queued during Promise 1)
// setTimeout 1 โ task
// setTimeout 2 โ task
๐กWhy microtasks run before the next task
After the current task completes, the engine drains the entire microtask queue before picking the next task. This ensures Promises always resolve/reject before the next setTimeout callback runs.
Blocking the Event Loop
Since JS is single-threaded, blocking code prevents everything else from running:
// โ Blocks the event loop โ UI freezes
function heavyWork() {
let result = 0;
for (let i = 0; i < 1_000_000_000; i++) {
result += i;
}
return result;
}
// Nothing else can run while this loop executes
Solutions:
- Break work into chunks with
setTimeout(fn, 0) - Use Web Workers for truly parallel computation
- Offload to the server for expensive calculations
// โ Chunked work โ yields to the event loop between chunks
function processInChunks(items, chunkSize = 1000) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
process(items[index]);
}
if (index < items.length) {
setTimeout(processChunk, 0); // yield, run next chunk later
}
}
processChunk();
}
requestAnimationFrame
For smooth animations, use requestAnimationFrame instead of setTimeout:
function animate() {
// This runs before each frame paint (~60fps)
updatePositions();
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
rAF callbacks run after microtasks and before the browser paints, ensuring smooth 60fps animation.
Async/Await and the Event Loop
await suspends the async function and schedules the rest as a microtask:
async function run() {
console.log("A");
await Promise.resolve();
console.log("C"); // scheduled as microtask
}
run();
console.log("B");
// Output: A, B, C
Summary Diagram
Call Stack Web APIs Task Queue Microtask Queue
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
[main script] โ setTimeout() โ [timer cb]
โ fetch() โ [response cb]
โ Promise.then() โ [promise cb]
โ addEventListener โ [click cb]
Event Loop: stack empty? โ drain microtask queue โ pick next task
Key Takeaways
- JavaScript is single-threaded โ one thing runs at a time
- The call stack tracks the current execution
- Async operations go to Web APIs, then callbacks enter queues
- Microtask queue (Promises, queueMicrotask) runs before task queue (setTimeout)
- After every task, the entire microtask queue is drained before the next task
- Never block the event loop โ use async, chunks, or Web Workers for heavy work
Ready to test your knowledge?
Take a quiz on what you just learned.