JavaScript Event Loop & Async Programming Deep Dive: Complete Chain from Macrotasks to Microtasks

前端工程

It Started With a Bug

Last year a colleague wrote a polling function. After 30 seconds, the browser froze solid:

function pollServer() {
  while (true) {
    const response = fetch('/api/status');  // meant to poll every second
    // ...
  }
}

He couldn't spot the issue immediately — fetch is async, so how does while(true) block? This is the classic "mistaking async for multithreading" trap. while(true) runs synchronously on the main thread, so the event loop never gets a chance to execute fetch's callback. Understanding the event loop is understanding JavaScript's single-threaded concurrency model. Once you get it, all those Promise execution-order interview questions become predictable.


The Call Stack

The JavaScript engine has exactly one call stack. Function calls push on, returns pop off:

function multiply(a, b) {
  return a * b;             // 4. execute multiply, return
}

function square(n) {
  const result = multiply(n, n);  // 3. call multiply, push
  return result;             // 5. return
}

function main() {
  const value = square(5);  // 2. call square, push
  console.log(value);       // 6. log 25
}

main();  // 1. call main, push
bottom → top of stack

main() pushed
main() → square(5) pushed
main() → square(5) → multiply(5, 5) pushed
main() → square(5) → multiply returns → popped
main() → square returns → popped
main() → console.log(25) pushed → popped
main returns → popped
stack empty

Stack overflow is RangeError: Maximum call stack size exceeded — usually infinite recursion. Chrome's call stack limit is roughly 10,000 frames.


The Task Queue (Macrotasks)

When the call stack is empty, the event loop picks one task from the task queue. Each item in the task queue is a macrotask.

What creates macrotasks?

Source Example
setTimeout / setInterval setTimeout(() => {}, 0)
I/O operations File reads, network request callbacks
UI rendering requestAnimationFrame (pre-render), render frame
User interaction click, keydown event callbacks
setImmediate Node.js only
MessageChannel port.postMessage()
console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

console.log('3');

// Output: 1 → 3 → 2
// setTimeout's callback goes to the macrotask queue
// and must wait for the current call stack to clear

Even setTimeout(fn, 0)fn waits for all current synchronous code to finish. It's not "0ms delay," it's "as soon as possible, but not in the current tick."


Microtasks

Microtasks are an ECMAScript specification concept. After each macrotask finishes, the event loop drains the entire microtask queue before picking the next macrotask.

What creates microtasks?

Source Example
Promise.then/catch/finally Promise.resolve().then(fn)
async/await (code after await) await something
queueMicrotask queueMicrotask(fn)
MutationObserver DOM change callbacks

This is the core interview question pattern: microtasks execute before next macrotask.

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => console.log('3'));
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => console.log('5'), 0);
});

console.log('6');

// Output: 1 → 6 → 4 → 2 → 3 → 5
//
// Breakdown:
// 1. Synchronous: 1, 6
// 2. Microtask queue: [then(()=>4)] → prints 4, pushes setTimeout(5) to macrotask
// 3. Macrotask queue: [setTimeout(2), setTimeout(5)]
// 4. First macrotask: prints 2, then(()=>3) goes to microtask
// 5. Drain microtask: prints 3
// 6. Second macrotask: prints 5

The Complete Event Loop Model

┌──────────────────────────────────────┐
│          Call Stack (sync)            │
│  ┌────────────────────────────────┐  │
│  │ function A → function B → ...  │  │
│  └────────────────────────────────┘  │
│          ↓ stack empty                │
│  ┌────────────────────────────────┐  │
│  │   Microtask Queue (drain all)   │  │
│  │  then → then → queueMicrotask  │  │
│  └────────────────────────────────┘  │
│          ↓ microtasks empty           │
│  ┌────────────────────────────────┐  │
│  │  Macrotask Queue (pick one)     │  │
│  │  setTimeout → click → I/O → ...│  │
│  └────────────────────────────────┘  │
│          ↓ back to call stack          │
│          repeat...                    │
└──────────────────────────────────────┘

One complete tick:

  1. Pick one macrotask, execute it
  2. All microtasks created during execution drain in the same tick
  3. Microtasks created by microtasks also drain in the same tick (recursive drain)
  4. Render (if needed)
  5. Pick next macrotask

Compound 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');

// Output: script start → async1 start → async2 → promise1
//         → script end → async1 end → promise2 → setTimeout
//
// Key insight:
// - Code inside Promise constructor is synchronous
// - Code before await in async functions is synchronous
// - await is equivalent to Promise.resolve().then(...), so code after await enters microtask queue

async/await Under the Hood

async/await is syntactic sugar over Generator + Promise.

// What we write:
async function fetchUser() {
  const response = await fetch('/api/user');
  const user = await response.json();
  return user;
}

// Equivalent to:
function fetchUser() {
  return Promise.resolve().then(() => {
    return fetch('/api/user');
  }).then((response) => {
    return response.json();
  });
}

A more precise Generator-based polyfill:

function asyncToGenerator(generatorFunc) {
  return function () {
    const gen = generatorFunc.apply(this, arguments);

    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result;
        try {
          result = gen[key](arg);
        } catch (error) {
          return reject(error);
        }

        const { value, done } = result;

        if (done) {
          return resolve(value);
        } else {
          return Promise.resolve(value).then(
            (val) => step('next', val),
            (err) => step('throw', err)
          );
        }
      }

      step('next');
    });
  };
}

Critical detail: the expression after await is wrapped with Promise.resolve(). If the value isn't already a Promise, it's automatically converted to a resolved Promise. So await 42 is equivalent to await Promise.resolve(42).


Node.js Event Loop

Node.js's event loop is more complex than the browser's — it has 6 phases, each with its own FIFO queue:

   ┌───────────────────────┐
┌─>│        timers         │  setTimeout, setInterval callbacks
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │   pending callbacks   │  Deferred I/O callbacks
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │  Internal only
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │        poll           │  Retrieve new I/O events (core phase)
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │        check          │  setImmediate callbacks
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │    close callbacks    │  socket.on('close', ...)
│  └──────────┬────────────┘
└──────────────┘
      After each phase → drain microtask queue

Node.js vs Browser Key Differences

Difference Browser Node.js
Macrotask types setTimeout, I/O, UI render 6 phases, each with different queue
setImmediate N/A Executes in check phase
process.nextTick N/A Priority higher than microtasks
Microtask timing After each macrotask At phase transitions + after each macrotask

process.nextTick Trap

console.log('1');

process.nextTick(() => {
  console.log('2');
  process.nextTick(() => console.log('3'));
});

Promise.resolve().then(() => console.log('4'));

console.log('5');

// Output: 1 → 5 → 2 → 3 → 4
// nextTick has priority over Promise microtasks!
// And nextTick inside nextTick inserts at the head of the queue

process.nextTick doesn't belong to any event loop phase. It executes immediately after the current operation completes, before the event loop continues. Recursive calls starve the event loop:

// ❌ Dangerous: event loop never advances
function starve() {
  process.nextTick(starve);
}

Performance Tuning: Avoiding Long Tasks

Problem: Rendering 10,000 Items

// ❌ Main thread freezes for seconds
function renderAll(items) {
  items.forEach(item => {
    const div = document.createElement('div');
    div.textContent = item.name;
    container.appendChild(div);
  });
}

Solution 1: Time Slicing

function renderChunked(items, chunkSize = 100) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      const div = document.createElement('div');
      div.textContent = items[index].name;
      container.appendChild(div);
    }

    if (index < items.length) {
      setTimeout(processChunk, 0);  // Yield control to browser
    }
  }

  processChunk();
}

Solution 2: requestIdleCallback

function renderIdle(items) {
  let index = 0;

  function processIdle(deadline) {
    while (index < items.length && deadline.timeRemaining() > 1) {
      const div = document.createElement('div');
      div.textContent = items[index].name;
      container.appendChild(div);
      index++;
    }

    if (index < items.length) {
      requestIdleCallback(processIdle);
    }
  }

  requestIdleCallback(processIdle);
}

Solution 3: Web Worker (True Parallelism)

// main.js
const worker = new Worker('/workers/data-processor.js');

worker.postMessage({ data: rawData, action: 'transform' });

worker.onmessage = (event) => {
  renderData(event.data);
};

// workers/data-processor.js
self.onmessage = (event) => {
  const { data, action } = event.data;

  if (action === 'transform') {
    const processed = data.map(item => ({
      ...item,
      displayName: item.name.toUpperCase(),
      formattedDate: new Date(item.createdAt).toLocaleDateString(),
    }));

    self.postMessage(processed);
  }
};

Choosing between the three:

Scenario Recommendation Rationale
DOM batch operations Time slicing Workers can't touch DOM
Non-urgent background tasks requestIdleCallback Uses idle time
CPU-intensive computation Web Worker True separate thread
Large streaming data ReadableStream + Worker Streaming processing

Monitoring Microtask Buildup

// Detect long-running microtask chains
let microtaskCount = 0;
const originalThen = Promise.prototype.then;

Promise.prototype.then = function (onFulfilled, onRejected) {
  microtaskCount++;

  if (microtaskCount > 1000) {
    console.warn('Possible microtask buildup! Count:', microtaskCount);
    console.trace();
  }

  setTimeout(() => {
    microtaskCount = Math.max(0, microtaskCount - 1);
  }, 0);

  return originalThen.call(this, onFulfilled, onRejected);
};

More practical: monitor long tasks with Performance Observer:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`Long task detected: ${entry.duration.toFixed(1)}ms`);
      // Report to monitoring system
    }
  }
});

observer.observe({ type: 'longtask', buffered: true });

Common Interview Questions

Q: setTimeout(fn, 0) vs Promise.resolve().then(fn) — which runs first?

A: Promise.then runs first. It's a microtask; setTimeout is a macrotask. The microtask queue must drain after every macrotask.

Q: Where does requestAnimationFrame fit in the event loop?

A: Before the render phase. The browser may execute multiple macrotasks before one render. rAF runs just before rendering — ideal for animations.

Q: In Node.js, setTimeout(fn, 0) vs setImmediate(fn) — which runs first?

A: In the main module, it's non-deterministic (depends on process performance). But inside an I/O callback, setImmediate always runs before setTimeout.

Q: Does code after await always run in a microtask?

A: Not always. If the value after await is already resolved (e.g., await 42), V8 may optimize it to run synchronously. It depends on engine implementation.


Try these browser-local tools — no sign-up required →

#JavaScript#事件循环#异步编程#Promise#教程