JavaScript Event Loop: Microtasks and Macrotasks

JavaScript runs on a single thread. There's no parallelism in your code—just one call stack executing one thing at a time. Yet somehow, JavaScript handles network requests, user interactions, and...

Key Insights

  • Microtasks (Promises, queueMicrotask) always execute before macrotasks (setTimeout, setInterval), and the entire microtask queue drains before the next macrotask runs.
  • A single recursive microtask can starve the browser’s rendering pipeline and freeze your UI—use macrotasks when you need to yield to the browser.
  • Understanding execution order isn’t academic trivia; it’s essential for debugging race conditions, preventing UI jank, and writing predictable async code.

Why the Event Loop Matters

JavaScript runs on a single thread. There’s no parallelism in your code—just one call stack executing one thing at a time. Yet somehow, JavaScript handles network requests, user interactions, and timers without blocking. The event loop makes this possible.

Most developers have a vague understanding that “async stuff happens later.” That’s enough until it isn’t. I’ve seen production bugs where a state update from a Promise callback overwrote a synchronous update, creating a race condition that only appeared under specific timing. I’ve watched developers spend hours debugging why their DOM manipulation happened before their data fetch “completed,” even though the Promise had resolved.

These bugs stem from not understanding when code actually runs. The event loop has rules, and once you know them, async behavior becomes predictable.

The Call Stack and Task Queue Basics

Synchronous code executes immediately on the call stack. Each function call pushes a frame onto the stack; each return pops one off. When the stack is empty, JavaScript looks for work elsewhere.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

console.log(square(5)); // 25

This runs top-to-bottom: square calls multiply, multiply returns, square returns, console.log outputs. No surprises.

Now add asynchronous code:

console.log('Start');

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

console.log('End');

// Output:
// Start
// End
// Timeout callback

Even with a 0ms delay, the timeout callback runs last. Why? Because setTimeout doesn’t execute the callback—it schedules it. The callback goes into a task queue, and the event loop only processes that queue when the call stack is empty.

The event loop’s core job: check if the call stack is empty, then pull work from the queues. But there’s not just one queue.

Macrotasks Explained

Macrotasks (sometimes just called “tasks”) include:

  • setTimeout and setInterval callbacks
  • I/O operations (Node.js)
  • UI rendering events
  • requestAnimationFrame (though this has special timing)
  • setImmediate (Node.js)

The critical rule: one macrotask per event loop iteration. After executing a macrotask, the browser can render, handle user input, then pick up the next macrotask.

setTimeout(() => console.log('Timeout 1'), 0);
setTimeout(() => console.log('Timeout 2'), 0);
setTimeout(() => console.log('Timeout 3'), 0);

// Output (in order):
// Timeout 1
// Timeout 2
// Timeout 3

Each timeout callback is a separate macrotask. They execute in order, but between each one, the browser has an opportunity to do other work. This is why setTimeout(..., 0) is often used to “yield” to the browser—you’re saying “run this later, after you’ve had a chance to breathe.”

Microtasks Explained

Microtasks are a higher-priority queue. They include:

  • Promise callbacks (.then(), .catch(), .finally())
  • queueMicrotask() callbacks
  • MutationObserver callbacks
  • async/await continuations (after await)

The critical rule: the entire microtask queue drains before the next macrotask. Every single microtask runs, including any new microtasks added while processing, before the event loop moves on.

console.log('Script start');

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

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

console.log('Script end');

// Output:
// Script start
// Script end
// Promise 1
// Promise 2
// setTimeout

The Promise callbacks run before setTimeout despite both being scheduled at roughly the same time. The synchronous code finishes first (emptying the call stack), then all microtasks run, then the macrotask.

Execution Order: Putting It All Together

Here’s the event loop algorithm, simplified:

  1. Execute synchronous code until the call stack is empty
  2. Process all microtasks in the microtask queue
  3. Render (if needed—browser may skip if nothing changed)
  4. Execute one macrotask from the macrotask queue
  5. Go to step 2

Let’s trace through a complex example:

console.log('1: Sync');

setTimeout(() => {
  console.log('2: Macrotask 1');
  Promise.resolve().then(() => console.log('3: Microtask inside macrotask'));
}, 0);

Promise.resolve()
  .then(() => {
    console.log('4: Microtask 1');
    setTimeout(() => console.log('5: Macrotask scheduled from microtask'), 0);
  })
  .then(() => console.log('6: Microtask 2'));

setTimeout(() => console.log('7: Macrotask 2'), 0);

console.log('8: Sync end');

// Output:
// 1: Sync
// 8: Sync end
// 4: Microtask 1
// 6: Microtask 2
// 2: Macrotask 1
// 3: Microtask inside macrotask
// 7: Macrotask 2
// 5: Macrotask scheduled from microtask

Walking through it:

  1. Sync phase: Logs 1 and 8. Schedules two setTimeout macrotasks and one Promise microtask.
  2. Microtask phase: Runs Promise chain. Logs 4, schedules another setTimeout, logs 6.
  3. Macrotask 1: Logs 2, schedules a microtask.
  4. Microtask phase: Logs 3.
  5. Macrotask 2: Logs 7.
  6. Macrotask 3: Logs 5 (the one scheduled from the microtask).

Notice how the microtask scheduled inside the first macrotask runs before the second macrotask. Microtasks always jump the queue.

Common Pitfalls and Performance Implications

Microtask Starvation

Because microtasks drain completely before yielding, a recursive microtask can freeze your page:

// DON'T DO THIS
function recursiveMicrotask() {
  queueMicrotask(() => {
    console.log('Processing...');
    recursiveMicrotask(); // Schedules another microtask
  });
}

recursiveMicrotask();
// The browser will never render or respond to clicks

This creates an infinite microtask loop. The queue never empties, so the event loop never reaches the render step or processes macrotasks. Your UI is frozen.

Compare with the macrotask version:

// This yields to the browser between iterations
function recursiveMacrotask() {
  setTimeout(() => {
    console.log('Processing...');
    recursiveMacrotask();
  }, 0);
}

recursiveMacrotask();
// Browser can render and respond between each iteration

Still infinite, but the browser gets breathing room between each callback.

When to Use Which

Use microtasks when:

  • You need something to run immediately after the current code, before any rendering
  • You’re chaining Promise-based operations
  • You need to batch multiple synchronous state changes before observers fire

Use macrotasks when:

  • You need to yield to the browser (let it render, handle input)
  • You’re breaking up long-running work to avoid blocking
  • You want to ensure your code runs after rendering
// Use microtask: ensure state is updated before any observers fire
queueMicrotask(() => {
  this.state = newState;
  this.notifyObservers();
});

// Use macrotask: let the browser paint the loading spinner first
showLoadingSpinner();
setTimeout(() => {
  doExpensiveWork();
  hideLoadingSpinner();
}, 0);

Practical Takeaways

Predict execution order by categorizing operations. When debugging async code, mentally label each operation: sync, microtask, or macrotask. Sync runs first, then all microtasks, then one macrotask, repeat.

Use queueMicrotask for immediate-but-async execution. It’s cleaner than Promise.resolve().then() and signals intent. It’s available in all modern browsers and Node.js.

Break up long microtask chains. If you’re processing a large array with Promises, consider batching with setTimeout to avoid UI freezes.

Console.log timestamps lie. The console doesn’t always display logs in execution order, especially in browsers. When debugging timing issues, use numbered logs or a counter variable.

Remember async/await is Promise-based. Code after await is a microtask. This trips people up:

async function example() {
  console.log('Before await');
  await Promise.resolve();
  console.log('After await'); // This is a microtask!
}

example();
console.log('After calling example');

// Output:
// Before await
// After calling example
// After await

The event loop isn’t magic—it’s a deterministic algorithm. Learn the rules, and async JavaScript becomes predictable. Ignore them, and you’ll keep shipping timing bugs that only appear in production.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.