正在加载,请稍候…

JavaScript Event Loop Explained: Async, Callbacks, Promises, and microtasks

Understand the JavaScript event loop, call stack, task queue, and microtask queue. Learn why async code works the way it does, and how to write non-blocking JavaScript.

JavaScript Event Loop Explained: Async, Callbacks, Promises, and Microtasks

The event loop is the heart of JavaScript's concurrency model. Understanding it explains why setTimeout(fn, 0) doesn't run immediately, why Promises behave the way they do, and how to write truly non-blocking code.

JavaScript Is Single-Threaded

JavaScript has one main thread. It can only do one thing at a time. But it can handle thousands of concurrent operations through an event-driven, non-blocking model.

The Key Components

┌─────────────────────────────────────────────┐
│                  Call Stack                  │
│  (executes synchronous code, LIFO order)     │
├─────────────────────────────────────────────┤
│             Web APIs / Node APIs             │
│  (setTimeout, fetch, fs.readFile, etc.)      │
├──────────────────────┬──────────────────────┤
│   Microtask Queue    │     Task Queue        │
│  (Promises, queueMicrotask, MutationObserver)│  (setTimeout, setInterval, I/O) │
└──────────────────────┴──────────────────────┘
              ↑ Event Loop picks from here

Priority order:

  1. Call Stack runs until empty
  2. Microtask Queue drains completely (all microtasks)
  3. One task from the Task Queue
  4. Repeat

Step-by-Step Example

console.log('1'); // Sync — goes on call stack immediately

setTimeout(() => {
  console.log('2 - setTimeout'); // Async — goes to Task Queue after 0ms
}, 0);

Promise.resolve().then(() => {
  console.log('3 - Promise'); // Async — goes to Microtask Queue
});

console.log('4'); // Sync

// Output order: 1, 4, 3 - Promise, 2 - setTimeout

Why?

  • 1 and 4 run synchronously (call stack)
  • Promise callback goes to microtask queue (higher priority)
  • setTimeout callback goes to task queue (lower priority)
  • Microtasks drain BEFORE next task queue item

Detailed Execution Model

console.log('start'); // 1

setTimeout(() => console.log('timeout 1'), 0); // Task queue

Promise.resolve()
  .then(() => {
    console.log('promise 1'); // Microtask
    return 'value';
  })
  .then(() => {
    console.log('promise 2'); // Microtask (chained)
  });

queueMicrotask(() => console.log('microtask')); // Microtask

setTimeout(() => console.log('timeout 2'), 0); // Task queue

console.log('end'); // 2

// Output:
// start
// end
// promise 1
// promise 2
// microtask
// timeout 1
// timeout 2

Why This Matters in Practice

UI Rendering

// ❌ Freezes the UI — blocking loop
for (let i = 0; i < 10_000_000; i++) {
  heavyWork(i);
}
// Browser can't render or respond to input during this!

// ✅ Yield to browser between chunks
async function processInChunks(data) {
  for (let i = 0; i < data.length; i++) {
    processItem(data[i]);
    
    // Every 1000 items, yield to allow rendering
    if (i % 1000 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

setTimeout(fn, 0) Is Not Instant

// setTimeout(fn, 0) doesn't run immediately
// It runs after:
// 1. Current synchronous code finishes
// 2. ALL microtasks drain
// 3. Then the setTimeout callback runs (as a task)

function example() {
  setTimeout(() => console.log('later'), 0);
  
  // Even 1 million iterations run before setTimeout
  for (let i = 0; i < 1_000_000; i++) {
    // All synchronous
  }
  
  console.log('sync done'); // Runs before 'later'
}

Promise Microtask Starvation

// ⚠️ Infinite microtasks starve the task queue
function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks); // Infinite chain!
  // setTimeout callbacks NEVER run — microtasks always drain first
}

// ✅ Use setImmediate / setTimeout for yielding to tasks
async function safeLoop() {
  while (true) {
    await new Promise(resolve => setTimeout(resolve, 0)); // Yields to task queue
    processNextItem();
  }
}

Async/Await Under the Hood

// async/await is syntactic sugar over Promises
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`); // Pause here
  const data = await response.json();                  // Pause here
  return data;
}

// Equivalent Promise chain:
function fetchUser(id) {
  return fetch(`/api/users/${id}`)          // Microtask
    .then(response => response.json())         // Microtask
    .then(data => data);
}

// Both work the same way under the hood:
// 1. fetch() initiates I/O (goes to Web APIs)
// 2. .then() callback is queued as microtask when fetch resolves
// 3. Execution resumes at the .then()

Node.js Event Loop (Slightly Different)

Node.js has additional phases:

┌───────────────┐
│   timers      │  ← setTimeout, setInterval callbacks
├───────────────┤
│ pending cbs   │  ← I/O error callbacks
├───────────────┤
│   idle/prep   │  ← Internal use
├───────────────┤
│     poll      │  ← Retrieve new I/O events (most work happens here)
├───────────────┤
│    check      │  ← setImmediate callbacks
├───────────────┤
│ close events  │  ← socket.on('close') etc.
└───────────────┘
       ↑
  Each phase drains its queue, then microtasks run between phases
// Node.js: setImmediate vs setTimeout(fn, 0)
setImmediate(() => console.log('setImmediate'));    // Runs in 'check' phase
setTimeout(() => console.log('setTimeout'), 0);    // Runs in 'timers' phase
process.nextTick(() => console.log('nextTick'));    // Runs before everything else (next tick queue)

// Order is usually: nextTick → setTimeout or setImmediate (order varies)

Common Async Pitfalls

// ❌ Sequential awaits when parallel is possible
async function loadPage() {
  const user = await fetchUser();         // Wait 200ms
  const posts = await fetchPosts();       // Wait 200ms AFTER user
  const ads = await fetchAds();           // Wait 200ms AFTER posts
  // Total: ~600ms
}

// ✅ Parallel with Promise.all
async function loadPageFast() {
  const [user, posts, ads] = await Promise.all([
    fetchUser(),   // All start simultaneously
    fetchPosts(),
    fetchAds(),
  ]);
  // Total: ~200ms (only as long as the slowest)
}

// ✅ Promise.allSettled — don't fail if one rejects
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
results.forEach(result => {
  if (result.status === 'fulfilled') console.log(result.value);
  if (result.status === 'rejected') console.error(result.reason);
});

Mental Model

Think of it like a restaurant:

  • Cook = JavaScript engine (single thread, does the actual work)
  • Orders = synchronous code (cook handles one at a time)
  • VIP orders = microtasks (handled between regular orders, always first)
  • Regular orders = tasks (setTimeout, I/O callbacks — handled after VIPs)
  • Prep work = Web APIs (happening in the background, not by the cook)

→ Measure async operation performance with the Benchmark Builder.