The JavaScript Event Loop & Concurrency Model: Why setTimeout(fn, 0) Doesn't Run Immediately
One of the most mind-bending aspects of JavaScript for new and experienced developers alike is understanding why asynchronous code behaves the way it does. You've probably heard that "JavaScript is single-threaded," but then how can it handle multiple operations simultaneously without blocking?
The answer lies in JavaScript's Event Loop and Concurrency Model — the engine that powers non-blocking I/O and makes JavaScript feel multithreaded even though it isn't.
The Golden Rule
JavaScript has a single call stack (single-threaded execution), but it achieves concurrency through the Event Loop, which coordinates between the Call Stack, Web APIs, and Task Queues.
Let's break this down step by step, from the fundamentals to how it impacts React applications.
Part 1: The Core Components
1. The Call Stack (Execution Context Stack)
The Call Stack is where JavaScript keeps track of function execution. It's a LIFO (Last In, First Out) data structure.
function first() {
console.log('First');
second();
}
function second() {
console.log('Second');
}
first();
Execution flow:
-
first()gets pushed onto the stack -
console.log('First')executes -
second()gets pushed onto the stack -
console.log('Second')executes -
second()pops off the stack -
first()pops off the stack
Key Point: JavaScript can only execute one function at a time. This is what "single-threaded" means.
2. Web APIs (Browser-provided functionality)
When you call functions like setTimeout, fetch, or addEventListener, these aren't actually part of the JavaScript engine — they're Web APIs provided by the browser (or Node.js runtime).
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
console.log('End');
Output:
Start
End
Timeout
Why? Even though the timeout is 0ms, the callback gets handed off to the Web API, allowing the Call Stack to continue executing synchronous code.
3. The Task Queue (Callback Queue / Macrotask Queue)
When Web APIs complete their work (like a timer finishing), they push callbacks into the Task Queue. This queue holds tasks waiting to be executed.
Examples of Macrotasks:
-
setTimeout/setIntervalcallbacks - I/O operations
- UI rendering events
-
requestAnimationFrame(in browsers)
4. The Microtask Queue (Job Queue)
There's a second, higher-priority queue called the Microtask Queue. This is where Promise callbacks live.
Examples of Microtasks:
-
.then()/.catch()/.finally()callbacks -
async/awaitcontinuations queueMicrotask()-
MutationObservercallbacks
Critical Rule: The Event Loop always empties the entire Microtask Queue before processing the next Macrotask.
5. The Event Loop
The Event Loop is the orchestrator. Here's its simplified algorithm:
while (true) {
// 1. Execute all synchronous code in the Call Stack
// 2. When the Call Stack is empty:
// a. Process ALL microtasks (Promise callbacks)
// b. Render UI updates (if in a browser)
// c. Process ONE macrotask (setTimeout, I/O)
// 3. Repeat
}
Part 2: The Classic setTimeout(fn, 0) Mystery
Let's examine why setTimeout(fn, 0) doesn't execute immediately:
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
Output:
A
D
C
B
Step-by-step breakdown:
-
console.log('A')executes immediately → Output:A -
setTimeoutcallback goes to the Web API, then to the Macrotask Queue -
Promise.resolve().then()callback goes to the Microtask Queue -
console.log('D')executes immediately → Output:D - Call Stack is now empty, so the Event Loop checks the Microtask Queue first → Output:
C - Finally, the Event Loop processes the Macrotask → Output:
B
Takeaway: Microtasks (Promises) always run before the next Macrotask (setTimeout), even if the setTimeout delay is 0.
Part 3: Practical Examples with Promises
Example 1: Nested Promises vs setTimeout
setTimeout(() => console.log('Timeout 1'), 0);
Promise.resolve()
.then(() => console.log('Promise 1'))
.then(() => console.log('Promise 2'))
.then(() => console.log('Promise 3'));
setTimeout(() => console.log('Timeout 2'), 0);
Output:
Promise 1
Promise 2
Promise 3
Timeout 1
Timeout 2
Why? All chained .then() callbacks are microtasks and run before moving to the next macrotask.
Example 2: Mixing Async/Await
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
async function asyncFunc() {
console.log('Async Start');
await Promise.resolve();
console.log('After Await');
}
asyncFunc();
console.log('End');
Output:
Start
Async Start
End
After Await
Timeout
Explanation:
-
await Promise.resolve()causes the rest of the function to be queued as a microtask - Synchronous code finishes first (
End) - Then microtasks run (
After Await) - Finally, the macrotask runs (
Timeout)
Part 4: How This Impacts React
1. State Updates and Batching
In React 18+, state updates are automatically batched in event handlers:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // Queued
setCount(count + 1); // Queued
setCount(count + 1); // Queued
// React batches these into a single re-render
};
return <button onClick={handleClick}>Count: {count}</button>;
}
Result: Only one re-render happens, not three. This batching relies on React's event loop integration.
2. useEffect Timing
useEffect(() => {
console.log('Effect runs');
setTimeout(() => {
console.log('Timeout in effect');
}, 0);
Promise.resolve().then(() => {
console.log('Promise in effect');
});
}, []);
console.log('Render complete');
Output:
Render complete
Effect runs
Promise in effect
Timeout in effect
Why?
-
useEffectcallbacks run after the render is committed to the DOM (as a microtask) - The Promise inside the effect is a nested microtask (runs immediately after the effect)
- The setTimeout is a macrotask (runs later)
3. React's Concurrent Rendering (React 18+)
React 18 introduced time-slicing, which breaks rendering work into chunks. This leverages the Event Loop to:
- Pause rendering to handle urgent updates (user input)
- Resume rendering during idle time
- Prioritize interactive updates over background work
import { useTransition } from 'react';
function SearchComponent() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const handleChange = (e) => {
// High priority: Update input immediately
setQuery(e.target.value);
// Low priority: Update search results
startTransition(() => {
setSearchResults(performExpensiveSearch(e.target.value));
});
};
return <input value={query} onChange={handleChange} />;
}
How it works: startTransition marks updates as "interruptible," allowing React to yield control back to the browser's Event Loop between chunks of work.
4. Common Pitfall: Stale Closures in Async Code
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs 0!
setCount(count + 1); // Wrong: captures stale 'count'
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps = closure captures initial 'count' value
return <div>{count}</div>;
}
Fix using functional updates:
setCount(prevCount => prevCount + 1); // Correct: uses current state
Why this happens: The setInterval callback is a macrotask that closes over the count value from when the effect ran. Since the effect only runs once ([] dependencies), it never gets the updated count.
Part 5: Visualizing the Event Loop
Here's a mental model:
┌─────────────────────────────────────────┐
│ JavaScript Call Stack │ ← Executes synchronous code
└─────────────────────────────────────────┘
↓ (when empty)
┌─────────────────────────────────────────┐
│ Microtask Queue (Promises) │ ← Process ALL microtasks
│ [Promise.then, async/await, ...] │
└─────────────────────────────────────────┘
↓ (when empty)
┌─────────────────────────────────────────┐
│ Macrotask Queue (setTimeout) │ ← Process ONE macrotask
│ [setTimeout, setInterval, I/O, ...] │
└─────────────────────────────────────────┘
↓ (repeat)
Quick Reference Cheat Sheet
| Feature | Queue Type | Priority | Examples |
|---|---|---|---|
| Synchronous Code | Call Stack | Highest | Regular function calls |
| Promises | Microtask Queue | High |
.then(), async/await
|
| setTimeout/setInterval | Macrotask Queue | Low | Timers, I/O |
| React State Updates | Batched (React 18+) | Varies |
setState in event handlers |
Key Takeaways
JavaScript is single-threaded but achieves concurrency through the Event Loop
Microtasks (Promises) always run before the next Macrotask (setTimeout)
setTimeout(fn, 0) doesn't run immediately — it waits for the Call Stack and Microtask Queue to clear
React state updates are batched to optimize re-renders
useEffect runs after rendering as a microtask, but callbacks inside can schedule macrotasks
Always use functional updates (setState(prev => prev + 1)) in async callbacks to avoid stale closures
Interview Tip
When asked about the Event Loop, explain it in layers:
- "JavaScript has a single Call Stack" (synchronous execution)
- "Web APIs handle async operations" (setTimeout, fetch)
- "Callbacks go into queues" (Microtask vs Macrotask)
- "The Event Loop coordinates execution" (empties microtasks first, then one macrotask)
- "In React, this affects state batching and useEffect timing"
Now go forth and never wonder why your setTimeout runs "late" again!
Top comments (0)