JavaScript is single-threaded โ only one piece of code runs at a time. Yet browsers handle network requests, timers, and user events simultaneously without freezing. This apparent contradiction is resolved by the event loop: a mechanism that coordinates the execution of synchronous code, asynchronous callbacks, microtasks, and macrotasks. Understanding how the event loop works is the key to understanding why setTimeout(fn, 0) does not run immediately, why Promises resolve before timers, and why blocking the main thread freezes the UI. Everything in async JavaScript builds on this foundation.
The JavaScript Runtime Components
| Component | Role | Holds |
|---|---|---|
| Call Stack | Executes synchronous code โ LIFO | Currently running function frames |
| Heap | Memory allocation for objects | Objects, closures, variables |
| Web APIs | Browser-provided async operations | setTimeout, fetch, DOM events, geolocation |
| Macrotask Queue | Scheduled callbacks โ runs one per loop tick | setTimeout, setInterval, I/O, UI events |
| Microtask Queue | High-priority callbacks โ drains completely before next macrotask | Promise .then/.catch/.finally, queueMicrotask |
| Event Loop | Coordinator โ picks next task when stack is empty | Logic: check microtasks โ macrotask โ render โ repeat |
Event Loop Tick Order
| Order | Step | Notes |
|---|---|---|
| 1 | Execute synchronous code until call stack is empty | Runs to completion โ cannot be interrupted |
| 2 | Drain the entire microtask queue | All .then callbacks, even ones added during drain |
| 3 | Render if needed (browser) | requestAnimationFrame runs here |
| 4 | Execute ONE macrotask from the queue | One setTimeout/setInterval callback per tick |
| 5 | Drain microtask queue again | Any microtasks queued by the macrotask |
| 6 | Repeat from step 3 | Continues until both queues are empty |
Macrotasks vs Microtasks
| Feature | Macrotask | Microtask |
|---|---|---|
| Examples | setTimeout, setInterval, DOM events, fetch callbacks |
Promise.then/catch/finally, queueMicrotask, MutationObserver |
| Processing | One per event loop tick | Entire queue drained before next macrotask |
| Priority | Lower โ runs after microtasks | Higher โ runs before next macrotask |
| Starvation risk | No โ always gets a turn eventually | Yes โ infinite microtasks will block macrotasks forever |
setTimeout(fn, 0) does not mean “run immediately” โ it means “schedule as a macrotask with minimum delay.” The callback will only run after the current synchronous code finishes AND all pending microtasks are drained. In practice, even setTimeout(fn, 0) has a minimum delay of ~4ms in browsers, and may be delayed further if the system is busy.queueMicrotask(fn) when you need to defer a callback to run after the current synchronous code but before any timers or I/O callbacks. It is semantically clearer than Promise.resolve().then(fn) and avoids creating an unnecessary Promise object. Both schedule a microtask, but queueMicrotask makes the intent explicit.setTimeout(processNextChunk, 0) or use a Web Worker to run the computation off the main thread entirely.Basic Example
// โโ Execution order demonstration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
console.log('1 โ sync start');
setTimeout(() => console.log('4 โ macrotask (setTimeout 0)'), 0);
Promise.resolve()
.then(() => console.log('3 โ microtask (Promise.then)'));
queueMicrotask(() => console.log('3b โ microtask (queueMicrotask)'));
console.log('2 โ sync end');
// Output order:
// 1 โ sync start
// 2 โ sync end
// 3 โ microtask (Promise.then)
// 3b โ microtask (queueMicrotask)
// 4 โ macrotask (setTimeout 0)
// โโ Multiple microtasks drain completely โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
setTimeout(() => console.log('macrotask'), 0);
Promise.resolve()
.then(() => {
console.log('microtask 1');
// Adding another microtask from inside a microtask:
return Promise.resolve();
})
.then(() => console.log('microtask 2'))
.then(() => console.log('microtask 3'));
// Output: microtask 1, microtask 2, microtask 3, macrotask
// The macrotask waits until ALL microtasks are drained
// โโ Visualising the call stack โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function third() { console.log('third'); }
function second() { third(); }
function first() { second(); }
// Call stack at deepest point:
// [third, second, first, anonymous (global)]
first(); // runs synchronously โ all frames pop before event loop checks queue
// โโ Blocking the event loop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function blockingWork(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {} // busy-wait โ blocks EVERYTHING
}
// During blockingWork(2000): no UI events, no timer callbacks, no renders
// โ Never do this for long operations
// โโ Non-blocking with chunked processing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function processInChunks(items, processOne, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
while (index < end) {
processOne(items[index++]);
}
if (index < items.length) {
setTimeout(processChunk, 0); // yield to event loop between chunks
}
}
processChunk();
}
const bigData = Array.from({ length: 10_000 }, (_, i) => i);
processInChunks(bigData, item => {
// process each item
}, 500);
// โโ Starvation example โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// This will run forever โ microtasks starve macrotasks
function starve() {
Promise.resolve().then(starve); // โ infinite microtask loop
}
// Don't call starve() โ here for illustration only
// โโ requestAnimationFrame timing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// rAF runs between microtask drain and next macrotask
// Guaranteed to run before the browser paints
let frameCount = 0;
function countFrames() {
frameCount++;
if (frameCount < 60) requestAnimationFrame(countFrames);
}
requestAnimationFrame(countFrames);
// Will count exactly 60 frames at ~60fps โ one per render cycle
How It Works
Step 1 โ The Call Stack Is Synchronous and Single-Threaded
When a function is called, a frame is pushed onto the call stack. When it returns, the frame is popped. The engine can only process one frame at a time. A deeply nested call chain like first() โ second() โ third() builds up three stack frames; all three must complete and pop before the event loop can check the task queues.
Step 2 โ Web APIs Run Outside the Engine
When you call setTimeout(fn, 1000), the engine hands the timer and callback to the browser’s Web API environment and immediately continues executing. The browser tracks the timer separately. After 1000ms, the browser places fn in the macrotask queue. The engine picks it up only when the call stack is empty.
Step 3 โ Microtasks Have Higher Priority Than Macrotasks
After every synchronous code block and after every macrotask, the event loop completely drains the microtask queue before doing anything else โ including rendering and running the next macrotask. This guarantees that Promise resolutions are always processed as a group before any timer fires, ensuring predictable ordering in async code.
Step 4 โ Rendering Happens Between Microtask Drain and Next Macrotask
The browser decides whether to repaint the screen between each event loop tick โ typically targeting 60 frames per second (every ~16ms). requestAnimationFrame callbacks run just before the paint step, giving you a hook to update the DOM at the exact right time for smooth animation.
Step 5 โ Long Synchronous Code Blocks Everything
While synchronous code is running, the event loop is frozen. No callbacks fire, no events are processed, and the browser cannot repaint. If your synchronous function takes 500ms, the page is completely unresponsive for 500ms. The solutions are: break work into chunks yielded via setTimeout, or move heavy computation to a Web Worker.
Real-World Example: Task Scheduler
// task-scheduler.js
// Demonstrates event loop by scheduling tasks with different priorities
class TaskScheduler {
#microtasks = [];
#macrotasks = [];
#rafTasks = [];
#log = [];
micro(fn, label) {
queueMicrotask(() => {
this.#log.push(`[microtask] ${label}`);
fn();
});
return this;
}
macro(fn, label, delay = 0) {
setTimeout(() => {
this.#log.push(`[macrotask ${delay}ms] ${label}`);
fn();
}, delay);
return this;
}
frame(fn, label) {
requestAnimationFrame(() => {
this.#log.push(`[rAF] ${label}`);
fn();
});
return this;
}
sync(fn, label) {
this.#log.push(`[sync] ${label}`);
fn();
return this;
}
printLog() {
console.log(this.#log.join('
'));
}
}
const scheduler = new TaskScheduler();
scheduler
.sync( () => {}, 'Start')
.macro( () => {}, 'Timer 100ms', 100)
.micro( () => {}, 'Promise 1')
.frame( () => {}, 'Animation frame')
.micro( () => {}, 'Promise 2')
.macro( () => {}, 'Timer 0ms', 0)
.sync( () => {}, 'End');
// Expected log order:
// [sync] Start
// [sync] End
// [microtask] Promise 1
// [microtask] Promise 2
// [rAF] Animation frame
// [macrotask 0ms] Timer 0ms
// [macrotask 100ms] Timer 100ms
Common Mistakes
Mistake 1 โ Assuming setTimeout(fn, 0) runs immediately
โ Wrong assumption:
setTimeout(() => console.log('first'), 0);
console.log('second');
// Logs: 'second' then 'first' โ not 'first' then 'second'
โ Understand: setTimeout schedules a macrotask after current sync + microtasks:
// If you need something after sync code, use a microtask:
queueMicrotask(() => console.log('runs after sync but before timers'));
Mistake 2 โ Blocking the event loop with heavy computation
โ Wrong โ freezes the UI for the entire duration:
button.addEventListener('click', () => {
const result = heavyComputation(1_000_000); // blocks for 2 seconds
display.textContent = result;
});
โ Correct โ use a Web Worker or yield between chunks:
button.addEventListener('click', async () => {
const worker = new Worker('./heavy-worker.js');
worker.postMessage({ count: 1_000_000 });
worker.onmessage = e => display.textContent = e.data.result;
});
Mistake 3 โ Infinite microtask loop starving the render
โ Wrong โ page never updates because microtasks never end:
function loop() { Promise.resolve().then(loop); }
loop(); // renders freeze โ browser appears hung
โ Correct โ use setInterval or requestAnimationFrame for repeating work:
function loop() { requestAnimationFrame(loop); }
requestAnimationFrame(loop); // runs once per frame โ browser can render
Quick Reference
| Task | Mechanism | Queue |
|---|---|---|
| Immediate deferred callback | queueMicrotask(fn) |
Microtask |
| After current sync + microtasks | Promise.resolve().then(fn) |
Microtask |
| Minimum delayed callback | setTimeout(fn, 0) |
Macrotask |
| Before next paint | requestAnimationFrame(fn) |
Render |
| Yield between chunks | setTimeout(nextChunk, 0) |
Macrotask |
| Off-main-thread compute | new Worker('./worker.js') |
Separate thread |