DEV Community

Tihomir Ivanov
Tihomir Ivanov

Posted on

The JavaScript Event Loop & Concurrency Model: Why setTimeout(fn, 0) Doesn't Run Immediately

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();
Enter fullscreen mode Exit fullscreen mode

Execution flow:

  1. first() gets pushed onto the stack
  2. console.log('First') executes
  3. second() gets pushed onto the stack
  4. console.log('Second') executes
  5. second() pops off the stack
  6. 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');
Enter fullscreen mode Exit fullscreen mode

Output:

Start
End
Timeout
Enter fullscreen mode Exit fullscreen mode

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 / setInterval callbacks
  • 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/await continuations
  • queueMicrotask()
  • MutationObserver callbacks

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
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Output:

A
D
C
B
Enter fullscreen mode Exit fullscreen mode

Step-by-step breakdown:

  1. console.log('A') executes immediately → Output: A
  2. setTimeout callback goes to the Web API, then to the Macrotask Queue
  3. Promise.resolve().then() callback goes to the Microtask Queue
  4. console.log('D') executes immediately → Output: D
  5. Call Stack is now empty, so the Event Loop checks the Microtask Queue first → Output: C
  6. 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);
Enter fullscreen mode Exit fullscreen mode

Output:

Promise 1
Promise 2
Promise 3
Timeout 1
Timeout 2
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Output:

Start
Async Start
End
After Await
Timeout
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Output:

Render complete
Effect runs
Promise in effect
Timeout in effect
Enter fullscreen mode Exit fullscreen mode

Why?

  • useEffect callbacks 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} />;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

Fix using functional updates:

setCount(prevCount => prevCount + 1); // Correct: uses current state
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. "JavaScript has a single Call Stack" (synchronous execution)
  2. "Web APIs handle async operations" (setTimeout, fetch)
  3. "Callbacks go into queues" (Microtask vs Macrotask)
  4. "The Event Loop coordinates execution" (empties microtasks first, then one macrotask)
  5. "In React, this affects state batching and useEffect timing"

Now go forth and never wonder why your setTimeout runs "late" again!

Top comments (0)