The Single-Threaded Problem
JavaScript runs on a single thread. One call stack. One thing at a time. This sounds like a severe limitation โ how does a language with one thread handle user clicks, network requests, timers, and animations simultaneously?
The answer: it doesn't do them simultaneously. It orchestrates them through the event loop โ a coordination mechanism that makes single-threaded JavaScript feel concurrent.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ JavaScript Runtime โ
โ โ
โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Call Stack โ โ Web APIs / Node APIs โ โ
โ โ โ โ โ โ
โ โ main() โ โ setTimeout() โ โ
โ โ fn1() โ โ fetch() โ โ
โ โ fn2() โ โ addEventListener() โ โ
โ โ โ โ requestAnimationFrame() โ โ
โ โโโโโโโโฌโโโโโโโโ โโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโ โ
โ โ โ Task Queues โ โ
โ โ โ โ โ
โ โ โ Microtask Queue: [p1, p2, ...] โ โ
โ โ โ Macrotask Queue: [t1, t2, ...] โ โ
โ โ โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ
โ โโโโโโโโโ Event Loop โโโ โ
โ (coordinator) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Call Stack
The call stack tracks function execution. When you call a function, it's pushed onto the stack. When it returns, it's popped off.
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);
Step-by-step call stack:
1. printSquare(4) โ printSquare โ
โโโโโโโโโโโโโโโ
2. square(4) โ square โ
โ printSquare โ
โโโโโโโโโโโโโโโ
3. multiply(4, 4) โ multiply โ
โ square โ
โ printSquare โ
โโโโโโโโโโโโโโโ
4. multiply returns 16 โ square โ
โ printSquare โ
โโโโโโโโโโโโโโโ
5. square returns 16 โ printSquare โ
โโโโโโโโโโโโโโโ
6. console.log(16) โ console.log โ
โ printSquare โ
โโโโโโโโโโโโโโโ
7. console.log returns โ printSquare โ
โโโโโโโโโโโโโโโ
8. printSquare returns โ โ (empty)
โโโโโโโโโโโโโโโ
Stack Overflow
The stack has a size limit. Recursive functions without a base case will exceed it:
function infinite() {
return infinite(); // no base case
}
infinite();
// RangeError: Maximum call stack size exceeded
Chrome's V8 has roughly 10,000-15,000 frames. The exact limit depends on the frame size (how many local variables each function has).
// Measure your stack limit
function measureStack(count = 0) {
try {
return measureStack(count + 1);
} catch {
return count;
}
}
console.log(measureStack()); // ~10,000-15,000 depending on engine
Web APIs and the Callback System
When JavaScript encounters an async operation (setTimeout, fetch, addEventListener), it delegates it to the browser's Web APIs (or Node.js C++ APIs). These run outside the JS thread. When the operation completes, a callback is placed in a queue.
console.log('Start');
setTimeout(() => {
console.log('Timer callback');
}, 2000);
console.log('End');
Timeline:
1. console.log('Start') โ Call Stack: [log] โ prints "Start"
2. setTimeout(cb, 2000) โ Call Stack: [setTimeout]
Web API starts 2s timer
3. console.log('End') โ Call Stack: [log] โ prints "End"
4. (call stack is empty)
... 2 seconds pass ...
5. Timer done โ cb moves to โ Macrotask Queue: [cb]
6. Event loop picks up cb โ Call Stack: [cb] โ prints "Timer callback"
Output:
Start
End
Timer callback
The key: setTimeout doesn't run the callback after 2 seconds. It places the callback in the queue after 2 seconds. The callback runs when the stack is empty and the event loop picks it up.
The Event Loop Algorithm
The event loop follows a specific algorithm on each "tick":
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Event Loop Cycle โ
โ โ
โ 1. Execute all synchronous code on the call stack โ
โ โ โ
โ โผ โ
โ 2. Call stack empty? โโโโ No โโโบ keep executing โ
โ โ โ
โ Yes โ
โ โ โ
โ โผ โ
โ 3. Process ALL microtasks (drain the microtask queue) โ
โ โ โ
โ โผ โ
โ 4. Microtask queue empty? โ
โ โ โ
โ Yes โ
โ โ โ
โ โผ โ
โ 5. [If rendering needed] requestAnimationFrame callbacks โ
โ โ โ
โ โผ โ
โ 6. [If rendering needed] Render / paint โ
โ โ โ
โ โผ โ
โ 7. Pick ONE macrotask from the queue โ
โ โ โ
โ โผ โ
โ 8. Go to step 1 โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The critical detail: all microtasks are processed before the next macrotask. And microtasks can enqueue more microtasks, which are also processed before moving on.
Macrotasks vs Microtasks
Two types of queues with different priorities:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Microtasks (high priority)โ Macrotasks (normal priority) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Promise .then/.catch โ setTimeout โ
โ queueMicrotask() โ setInterval โ
โ MutationObserver โ setImmediate (Node.js) โ
โ process.nextTick (Node) โ I/O callbacks โ
โ โ UI rendering events โ
โ โ requestAnimationFrame* โ
โ โ MessageChannel โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
* rAF has its own timing โ runs before paint, after microtasks
Execution Order Demo
console.log('1 - sync');
setTimeout(() => console.log('2 - macrotask (setTimeout)'), 0);
Promise.resolve().then(() => console.log('3 - microtask (Promise)'));
queueMicrotask(() => console.log('4 - microtask (queueMicrotask)'));
console.log('5 - sync');
// Output:
// 1 - sync
// 5 - sync
// 3 - microtask (Promise)
// 4 - microtask (queueMicrotask)
// 2 - macrotask (setTimeout)
Step by step:
console.log('1')โ sync, runs immediatelysetTimeoutโ registers macrotask, moves onPromise.thenโ registers microtask, moves onqueueMicrotaskโ registers microtask, moves onconsole.log('5')โ sync, runs immediately- Stack empty โ drain microtask queue โ logs 3, then 4
- Pick next macrotask โ logs 2
Microtasks Spawning Microtasks
Microtasks enqueued during microtask processing are processed in the same cycle:
console.log('1');
setTimeout(() => console.log('2 - macro'), 0);
Promise.resolve()
.then(() => {
console.log('3 - micro');
// This microtask is processed BEFORE the setTimeout
return Promise.resolve();
})
.then(() => {
console.log('4 - micro (chained)');
queueMicrotask(() => console.log('5 - micro (nested)'));
});
console.log('6');
// Output:
// 1
// 6
// 3 - micro
// 4 - micro (chained)
// 5 - micro (nested)
// 2 - macro
All microtasks (3, 4, 5) run before the macrotask (2), even though 4 and 5 were enqueued by earlier microtasks.
Warning: Infinite Microtask Loop
If microtasks keep spawning microtasks, the macrotask queue (and rendering) is starved:
// DON'T DO THIS โ browser will freeze
function infiniteMicro() {
queueMicrotask(infiniteMicro);
}
infiniteMicro(); // UI never updates, setTimeout never fires
setTimeout(fn, 0) Behavior
setTimeout(fn, 0) doesn't mean "run immediately." It means "run as soon as the stack is clear and any microtasks are done, via the macrotask queue."
console.log('A');
setTimeout(() => console.log('B'), 0);
console.log('C');
// Output: A, C, B
// Even with 0ms delay, B waits for the macrotask queue
Minimum Delay
Browsers enforce a minimum delay of ~4ms for nested setTimeout calls (after 5 consecutive immediate timeouts). This is per the HTML spec.
let start = Date.now();
let count = 0;
function measure() {
count++;
if (count <= 10) {
setTimeout(() => {
console.log(`${count}: ${Date.now() - start}ms`);
measure();
}, 0);
}
}
measure();
// First few: ~0-1ms
// After 5th: ~4ms each (browser throttling kicks in)
setTimeout for Yielding
Use setTimeout(fn, 0) to break up long-running tasks and let the browser breathe:
// BAD โ blocks the main thread, UI freezes
function processLargeArray(items) {
for (const item of items) {
heavyComputation(item); // 1000 items ร 10ms = 10s freeze
}
}
// GOOD โ process in chunks, yielding between them
async function processLargeArray(items, chunkSize = 50) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
for (const item of chunk) {
heavyComputation(item);
}
// Yield to the event loop โ let rendering and events happen
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
Promise Microtask Priority
Promises always resolve via the microtask queue. This gives them higher priority than timers.
setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
console.log('sync');
// Output:
// sync
// promise 1
// promise 2
// timeout 1
// timeout 2
A more complex example:
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
Let's trace this step by step:
Call Stack Execution:
1. log('script start') โ output: "script start"
2. setTimeout(cb, 0) โ registers macrotask
3. async1() โ enters async1
4. log('async1 start') โ output: "async1 start"
5. await async2() โ calls async2()
6. log('async2') โ output: "async2"
7. await pauses async1 โ remainder queued as microtask
8. new Promise executor runs
9. log('promise1') โ output: "promise1"
10. resolve() โ .then callback queued as microtask
11. log('script end') โ output: "script end"
Microtask Queue Processing:
12. async1 resumes
13. log('async1 end') โ output: "async1 end"
14. Promise .then runs
15. log('promise2') โ output: "promise2"
Macrotask Queue Processing:
16. setTimeout callback runs
17. log('setTimeout') โ output: "setTimeout"
Final output:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
requestAnimationFrame Placement
requestAnimationFrame (rAF) runs before the browser paints โ after microtasks, before the next macrotask. It's synchronized with the display refresh rate (usually 60fps = ~16.67ms).
console.log('1 - sync');
setTimeout(() => console.log('2 - macrotask'), 0);
requestAnimationFrame(() => console.log('3 - rAF'));
Promise.resolve().then(() => console.log('4 - microtask'));
console.log('5 - sync');
// Typical output:
// 1 - sync
// 5 - sync
// 4 - microtask
// 3 - rAF โ before paint, after microtasks
// 2 - macrotask โ after paint
Note: the exact ordering of rAF vs setTimeout can vary between browsers and frames. The reliable guarantee is: microtasks first, then rAF (before paint), then macrotasks.
One Event Loop Cycle (rendering frame):
โ Execute sync code โ
โ โ
โผ โ
โ Drain microtasks โ
โ โ
โผ โ
โ rAF callbacks โ โ runs before paint
โ โ
โผ โ
โ Layout + Paint โ โ browser renders
โ โ
โผ โ
โ One macrotask โ โ setTimeout, setInterval, etc.
โ โ
โผ โ
โ (next cycle) โ
Using rAF for Smooth Animations
function animate(element) {
let position = 0;
function step() {
position += 2;
element.style.transform = `translateX(${position}px)`;
if (position < 300) {
requestAnimationFrame(step); // schedule next frame
}
}
requestAnimationFrame(step);
}
rAF vs setTimeout for Animation
setTimeout(fn, 16):
- Not synced with display refresh
- Can cause janky animations (frame drops)
- Runs even when tab is hidden (wastes CPU)
requestAnimationFrame(fn):
- Synced with display refresh rate
- Smooth 60fps animations
- Automatically pauses when tab is hidden
- Browser can batch and optimize
Node.js Event Loop Differences
Node.js has a more granular event loop with specific phases:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโบโ timers โ โ setTimeout, setInterval
โ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ โ pending callbacks โ โ I/O errors, etc.
โ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ โ idle, prepare โ โ internal use
โ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ โ poll โ โ I/O callbacks
โ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ โ check โ โ setImmediate
โ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ โ close callbacks โ โ socket.on('close')
โ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ โ
โโโโโโโโโโโโโโโโโโ
Microtasks (Promise, process.nextTick) run between EVERY phase.
process.nextTick has even higher priority than Promise microtasks.
setImmediate vs setTimeout(0) in Node.js
// In Node.js โ order is non-deterministic in the main module:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Could be either order
// But inside an I/O callback, setImmediate always runs first:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Always: immediate, timeout
process.nextTick
process.nextTick is Node-specific and runs before Promise microtasks:
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// Output:
// nextTick
// promise
Debugging Event Loop Issues
Identifying Main Thread Blocking
// Simple main thread monitor
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const delay = now - lastCheck;
if (delay > 50) { // interval is ~16ms, >50ms means blocking
console.warn(`Main thread blocked for ${delay}ms`);
}
lastCheck = now;
}, 16);
Using Performance API
// Mark and measure async operations
performance.mark('fetch-start');
const data = await fetch('/api/data');
performance.mark('fetch-end');
performance.measure('fetch-duration', 'fetch-start', 'fetch-end');
const measure = performance.getEntriesByName('fetch-duration')[0];
console.log(`Fetch took ${measure.duration}ms`);
Long Task API
// Detect tasks that block the main thread for >50ms
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detected: ${entry.duration}ms`);
console.log('Task details:', entry);
}
});
observer.observe({ entryTypes: ['longtask'] });
Chrome DevTools Performance Tab
- Open DevTools โ Performance tab
- Click Record
- Interact with your page
- Stop recording
- Look for:
- Long yellow bars โ JavaScript execution blocking the thread
- Red triangles โ dropped frames
- Task durations โ anything > 50ms is a "long task"
Common Patterns
Yielding to the Event Loop
Modern approach using scheduler.yield() (where available) or fallback:
async function yieldToMain() {
if ('scheduler' in globalThis && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Yield every 10 items to keep UI responsive
if (i % 10 === 0) {
await yieldToMain();
}
}
}
Batching DOM Updates
// BAD โ forces layout recalculation between reads and writes
elements.forEach((el) => {
const height = el.offsetHeight; // read (forces layout)
el.style.height = height * 2 + 'px'; // write (invalidates layout)
});
// Each iteration: read โ layout โ write โ invalidate
// N elements = N forced layouts
// GOOD โ batch reads, then batch writes
const heights = elements.map((el) => el.offsetHeight); // all reads
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + 'px'; // all writes
});
});
// One layout calculation total
Idle Callback for Non-Critical Work
function doNonCriticalWork(deadline) {
while (deadline.timeRemaining() > 0 && workQueue.length > 0) {
const task = workQueue.shift();
task();
}
if (workQueue.length > 0) {
requestIdleCallback(doNonCriticalWork);
}
}
// Queue non-critical tasks
const workQueue = [];
function scheduleWork(task) {
workQueue.push(task);
if (workQueue.length === 1) {
requestIdleCallback(doNonCriticalWork);
}
}
// Usage
scheduleWork(() => analytics.track('page_view'));
scheduleWork(() => prefetchNextPage());
scheduleWork(() => lazyLoadImages());
Quiz: Predict the Output
Challenge 1
console.log('A');
setTimeout(() => console.log('B'), 0);
new Promise((resolve) => {
console.log('C');
resolve();
}).then(() => {
console.log('D');
});
console.log('E');
Answer: A, C, E, D, B
- A: sync
- C: Promise executor is sync
- E: sync
- D: microtask (Promise.then)
- B: macrotask (setTimeout)
Challenge 2
setTimeout(() => console.log('1'), 0);
setTimeout(() => console.log('2'), 0);
Promise.resolve()
.then(() => {
console.log('3');
setTimeout(() => console.log('4'), 0);
})
.then(() => console.log('5'));
Promise.resolve().then(() => console.log('6'));
Answer: 3, 6, 5, 1, 2, 4
- Microtasks first: 3, then 6 (both first-level .then), then 5 (chained .then from 3)
- Macrotasks in order: 1, 2, 4 (4 was scheduled last, during microtask processing)
Challenge 3
async function foo() {
console.log('foo start');
await bar();
console.log('foo end');
}
async function bar() {
console.log('bar');
}
console.log('start');
foo();
console.log('end');
Answer: start, foo start, bar, end, foo end
start: syncfoo start: sync (inside foo, before await)bar: sync (bar executes synchronously)end: sync (foo is paused at await)foo end: microtask (resumes after await)
Key Takeaways
- JavaScript is single-threaded โ the event loop makes it feel concurrent
- Call stack executes sync code โ one frame at a time, LIFO order
- Microtasks before macrotasks โ Promises always run before setTimeout
- ALL microtasks drain before next macrotask โ including microtasks spawned by microtasks
- setTimeout(fn, 0) is not immediate โ it waits for the stack to clear and microtasks to drain
- rAF runs before paint โ use it for animations, not setTimeout
- Long tasks block everything โ break up work, yield to the main thread
- Node.js has more phases โ timers, poll, check, each with microtask checkpoints
- process.nextTick > Promise โ in Node.js, nextTick has higher priority than Promise microtasks
- Use DevTools Performance tab โ the single best tool for diagnosing event loop issues