JavaScript Is Single-Threaded
JavaScript has a single call stack β only one function can run at a time. This simplicity means you never have to worry about race conditions between threads. However, it also means long-running synchronous code blocks everything, including the UI. The event loop is how JavaScript achieves concurrency without multiple threads.
1. Call Stack β where functions execute (LIFO). 2. Web APIs β browser/Node APIs that handle async work off-thread (timers, fetch, DOM events). 3. Macrotask Queue β callbacks ready to run after the current stack is empty. 4. Microtask Queue β high-priority callbacks (Promises, queueMicrotask) drained entirely before the next macrotask.
The Call Stack
The call stack is a LIFO (Last In, First Out) data structure. When a function is called, a new frame is pushed onto the stack. When the function returns, its frame is popped. If the stack ever overflows (too much recursion), you get a "Maximum call stack size exceeded" error.
function third() { console.log('third'); }
function second() { third(); console.log('second'); }
function first() { second(); console.log('first'); }
/*
Call stack progression:
[first] β push first
[first, second] β push second
[first, second, third] β push third
[first, second] β third returns, popped
[first] β second returns, popped
[] β first returns, popped
*/
first();
// Output: third β second β first
third second first
Web APIs β Async Work Off the Stack
When you call setTimeout, fetch, or add an event listener, the browser's Web APIs (or Node.js's libuv) handle the async work outside the JavaScript thread. When the work completes, the callback is placed into a queue β not directly back onto the call stack.
console.log('A'); // 1. Synchronous
setTimeout(() => {
console.log('B'); // 4. Macrotask (after ~0ms delay)
}, 0);
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(r => r.json())
.then(data => {
console.log('C: fetched', data.id); // 5. Microtask (after network)
});
Promise.resolve().then(() => {
console.log('D'); // 3. Microtask (before timeout)
});
console.log('E'); // 2. Synchronous
// Order: A β E β D β B β C (network response)
A E D B C: fetched 1
The Event Loop Algorithm
The event loop runs this algorithm continuously:
- Execute all synchronous code until the call stack is empty.
- Drain the microtask queue completely (run all microtasks, including any newly queued by microtasks).
- Render the page if needed (browser only).
- Take one macrotask from the macrotask queue and execute it.
- Go to step 2.
// Execution order quiz β predict before running
console.log('1');
setTimeout(() => console.log('2'), 0); // macrotask
setTimeout(() => console.log('3'), 0); // macrotask
Promise.resolve()
.then(() => {
console.log('4'); // microtask 1
return Promise.resolve();
})
.then(() => console.log('5')); // microtask 2
queueMicrotask(() => console.log('6')); // microtask (same queue)
console.log('7');
// Answer: 1 β 7 β 4 β 6 β 5 β 2 β 3
1 7 4 6 5 2 3
Macrotasks vs Microtasks
| Queue | Sources | When drained | Priority |
|---|---|---|---|
| Microtask | Promise .then/.catch/.finally, queueMicrotask(), MutationObserver | After every task β all microtasks run before next task | Higher |
| Macrotask | setTimeout, setInterval, setImmediate (Node), I/O callbacks, UI events | One at a time β then microtasks drain again | Lower |
If a microtask keeps queuing new microtasks (e.g., a Promise that resolves with another Promise that resolves with anotherβ¦), the macrotask queue β including UI rendering β will starve. The page freezes. Always ensure microtask chains terminate.
setTimeout(fn, 0) Explained
setTimeout(fn, 0) does not run fn immediately. It schedules fn as a macrotask to run as soon as possible β but only after the current call stack is empty and all microtasks have been drained.
// Use-case: defer heavy work to avoid blocking the UI
function processHeavyData(items) {
let index = 0;
function processChunk() {
const end = Math.min(index + 100, items.length);
for (; index < end; index++) {
// process items[index]
}
if (index < items.length) {
setTimeout(processChunk, 0); // yield to event loop, then continue
} else {
console.log('Processing complete');
}
}
setTimeout(processChunk, 0); // start without blocking caller
}
const bigArray = Array.from({ length: 350 }, (_, i) => i);
processHeavyData(bigArray);
console.log('Caller continues immediately');
// Use-case: ensure DOM is updated before reading layout
// setTimeout(() => console.log(element.offsetHeight), 0);
Caller continues immediately Processing complete
requestAnimationFrame Timing
requestAnimationFrame(callback) schedules a callback to run before the next browser repaint (typically every ~16.7ms at 60 fps). It sits between macrotasks and the render step in the event loop. Use it for animations β never setTimeout for visual updates.
// Smooth animation with requestAnimationFrame (browser only)
function animateBox(element, targetX, duration) {
const startX = 0;
const startTime = performance.now();
function frame(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-in-out cubic
const eased = progress < 0.5
? 4 * progress ** 3
: 1 - (-2 * progress + 2) ** 3 / 2;
element.style.transform = `translateX(${eased * targetX}px)`;
if (progress < 1) {
requestAnimationFrame(frame); // schedule next frame
} else {
console.log('Animation complete');
}
}
requestAnimationFrame(frame); // kick off first frame
}
// Usage: animateBox(document.querySelector('.box'), 300, 1000);
queueMicrotask(fn) adds fn to the microtask queue explicitly. It is equivalent to Promise.resolve().then(fn) but semantically clearer when you are not actually working with Promises. Use it when you need to defer work until after synchronous code completes but before any macrotask.
ποΈ Practical Exercise
Predict the output of this code, then run it to verify:
console.log('start');
setTimeout(() => console.log('timeout 1'), 0);
Promise.resolve().then(() => {
console.log('microtask 1');
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => console.log('microtask 2'));
});
setTimeout(() => console.log('timeout 3'), 0);
console.log('end');
Write your predicted order before revealing the answer.
π₯ Challenge Exercise
Implement a scheduler object with three methods: asap(fn) (runs as a microtask), defer(fn) (runs as a macrotask via setTimeout 0), and nextFrame(fn) (runs via requestAnimationFrame). Write a test that demonstrates the correct execution order: synchronous code β asap β nextFrame β defer.
Interview Questions
- Why is JavaScript single-threaded and how does it still handle async operations?
- What is the difference between the macrotask queue and the microtask queue?
- Why do Promise callbacks run before setTimeout callbacks with the same delay?
- What does setTimeout(fn, 0) actually do?
- What is the event loop algorithm β describe it step by step?
- What is requestAnimationFrame and how does it fit into the event loop?
π Summary
- JavaScript is single-threaded: one call stack, one thing running at a time.
- Web APIs handle async work off the stack; completed callbacks go to queues.
- The event loop algorithm: run stack β drain all microtasks β render β run one macrotask β repeat.
- Microtasks (Promises, queueMicrotask) always run before the next macrotask.
setTimeout(fn, 0)defers execution to the next macrotask slot β not immediately.requestAnimationFrameis the correct tool for browser animations β it aligns with repaint cycles.
Frequently Asked Questions
setImmediate(fn) is a Node.js-specific API that places the callback at the head of the check phase of the libuv event loop β always after I/O callbacks in the current iteration. setTimeout(fn, 0) queues in the timers phase. In practice, the order between them depends on context: inside an I/O callback, setImmediate always fires first.
A task (macrotask) is a unit of work the event loop picks from the task queue: a timer callback, an I/O callback, a script execution. An event is something that happens (a click, a network response) that the browser translates into a task by adding the event handler callback to the task queue.
Yes. Because the microtask queue is fully drained before the browser can render, a microtask that continuously queues new microtasks will prevent any rendering from occurring β effectively freezing the UI. Keep microtask chains short and terminating.