Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 004 / 100

The event loop: microtasks vs macrotasks for production

The ordering rule behind confusing async bugs.

Expona Admin8 min read

Introduction

You've probably watched a Promise resolve before a setTimeout fires, even though you queued them in the opposite order. Or you've seen rendering stutter while your app churns through async work. The event loop: microtasks vs macrotasks is the ordering rule that explains both. Understanding this distinction will stop you from chasing phantom race conditions and help you reason about timing in production systems.

Why the event loop matters

JavaScript is single-threaded. That means only one piece of code runs at a time. But browsers and Node.js need to handle timers, network requests, user input, rendering, and Promise callbacks—all concurrently. The event loop is the scheduler that decides which task runs next. Without knowing how it prioritizes work, async bugs feel random and debugging becomes guesswork.

The key insight: the event loop doesn't treat all async work equally. It has two queues, and it drains one before checking the other.

Macrotasks — setTimeout, setInterval, I/O callbacks and UI events

A macrotask is a coarse-grained unit of work scheduled from outside the JavaScript engine. Each macrotask runs alone, then the engine yields control to the browser or runtime to handle rendering, garbage collection, or other housekeeping.

Examples of macrotasks include:

  • setTimeout and setInterval
  • I/O callbacks (file reads, network responses)
  • UI events (click, scroll, input)
  • requestAnimationFrame (debatable, but often grouped with macrotasks)
  • setImmediate (Node.js only)

When you call setTimeout(() => console.log('hi'), 0), you're not running the callback immediately. You're placing it into the macrotask queue. The event loop will run it later, after all current synchronous code and microtasks finish.

Here's a real-world scenario: you're building a ride-hailing app and need to update the map UI after a location lookup completes. The HTTP response triggers a callback (macrotask), which runs after the current frame's microtasks drain. This can feel sluggish if you're not careful about what runs in between.

typescript

// Simulating a macrotask: setTimeout
console.log('1. Sync code');

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

console.log('2. More sync code');

// Output order:
// 1. Sync code
// 2. More sync code
// 4. Macrotask from setTimeout

Notice that even with a 0ms delay, the setTimeout callback waits for all synchronous code to finish first. That's the event loop's basic contract: finish the current execution context before moving to the macrotask queue.

Microtasks — Promise callbacks and queueMicrotask

A microtask is fine-grained work that the JavaScript engine schedules internally. Microtasks live in their own queue, separate from macrotasks, and they get higher priority.

Examples of microtasks include:

  • Promise.then(), Promise.catch(), Promise.finally()
  • queueMicrotask()
  • MutationObserver callbacks
  • process.nextTick() (Node.js, even higher priority than other microtasks)

When a Promise resolves, its callback doesn't run immediately. Instead, it's queued as a microtask. The event loop will run it as soon as the current macrotask finishes but before the next macrotask starts.

In an e-commerce platform, you might fetch a user's cart with a Promise, then fetch their wishlist with another Promise. Both callbacks queue as microtasks. They'll run back-to-back, before any timer or UI event handler fires.

typescript

// Microtask: Promise.then()
console.log('1. Sync code');

Promise.resolve().then(() => {
  console.log('3. Microtask from Promise');
});

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

console.log('2. More sync code');

// Output order:
// 1. Sync code
// 2. More sync code
// 3. Microtask from Promise
// 4. Macrotask from setTimeout

The Promise callback runs before the setTimeout callback, even though we registered the timeout first. That's because microtasks have priority over macrotasks in the event loop's scheduling algorithm.

The rule — After each task, the engine drains all microtasks before the next task

This is the core rule. After every macrotask completes, the event loop empties the entire microtask queue before moving to the next macrotask. It's not a one-at-a-time swap. It's a full drain.

Here's the pseudocode of the event loop:

typescript

while (eventLoop.waitForTask()) {
  // Step 1: Run one macrotask
  const task = eventLoop.nextMacrotask();
  task.execute();

  // Step 2: Drain ALL microtasks
  while (eventLoop.hasMicrotasks()) {
    const microtask = eventLoop.nextMicrotask();
    microtask.execute();
  }

  // Step 3: Render if needed, then loop
  if (isRepaintTime()) {
    repaint();
  }
}

This explains a lot. If you queue 10 Promises and then a setTimeout, all 10 Promises will resolve before the timeout fires. The engine drains the microtask queue completely before moving on.

Let's trace through a more complex example from a SaaS dashboard that loads user data:

typescript

console.log('1. Start');

// Macrotask 1: Timer
setTimeout(() => {
  console.log('5. setTimeout callback');
  
  // Queue a microtask from inside a macrotask
  Promise.resolve().then(() => {
    console.log('6. Microtask from inside setTimeout');
  });
}, 0);

// Microtask 1: Promise
Promise.resolve()
  .then(() => {
    console.log('3. First Promise');
    return fetch('/api/user');
  })
  .then(() => {
    console.log('4. After fetch');
  });

// Microtask 2: queueMicrotask
queueMicrotask(() => {
  console.log('2. queueMicrotask');
});

console.log('7. End of sync code');

// Output:
// 1. Start
// 7. End of sync code
// 2. queueMicrotask
// 3. First Promise
// 4. After fetch
// 5. setTimeout callback
// 6. Microtask from inside setTimeout

Walk through this carefully:

  1. Synchronous code runs first (1, 7)
  2. Then all microtasks drain (2, 3, 4)
  3. Then the first macrotask runs (5)
  4. Then any new microtasks are drained (6)

This is why Promises feel "synchronous" in some contexts. They're microtasks. They get processed immediately after the current work finishes, before any I/O or timer callback.

Starvation — Endless microtasks can block rendering and timers

Here's the danger: if your microtask queue never empties, the event loop never reaches step 3 (rendering) or step 4 (next macrotask). The browser freezes. Timers never fire. The page becomes unresponsive.

This is called microtask starvation. I've seen it in production when a CRM system queued recursive Promises inside event handlers without thinking about the cost.

typescript

// DANGEROUS: Infinite microtask loop
let count = 0;

function recursivePromise() {
  count++;
  if (count < 1000000) {
    Promise.resolve().then(recursivePromise);
  }
}

console.log('Start');
recursivePromise();

setTimeout(() => {
  console.log('This will take a very long time to run');
}, 0);

// The setTimeout callback is blocked until all 1 million Promises resolve.
// Meanwhile, the page is frozen. No rendering, no other timers, nothing.

The fix is to break microtask chains with macrotasks. Use setTimeout(..., 0) or setImmediate to yield control back to the browser and allow rendering and other timers to proceed.

typescript

// BETTER: Yield with a macrotask
let count = 0;

function processInBatches() {
  // Process 100 items, then yield
  for (let i = 0; i < 100 && count < 1000000; i++) {
    count++;
  }
  
  if (count < 1000000) {
    setTimeout(processInBatches, 0); // Yield control
  } else {
    console.log('Done');
  }
}

console.log('Start');
processInBatches();

setTimeout(() => {
  console.log('This runs much sooner now');
}, 0);

// The setTimeout callback fires after a reasonable time,
// not after a million Promise cycles.

In a ride-hailing app, if you're calculating route matches for thousands of drivers, batch the computations and yield periodically. Don't queue them all as Promises at once.

Common mistakes

Mistake 1: Assuming setTimeout(..., 0) runs first. It doesn't. All microtasks (Promises, queueMicrotask) run before it. Use microtasks if you want to run something as soon as possible, not macrotasks.

Mistake 2: Forgetting that Promise callbacks queue microtasks. A resolved Promise's .then() doesn't run immediately—it queues a microtask. If you're chaining many Promises, they all queue, and the browser won't render until they're done.

Mistake 3: Ignoring rendering constraints. The browser renders between macrotasks (or during idle time). If your microtasks take 100ms, the screen won't update for 100ms. Users see jank.

Mistake 4: Mixing async/await with setImmediate carelessly. async/await is syntactic sugar over Promises, so await queues microtasks. If you're trying to yield control, setImmediate or setTimeout is more direct.

Key takeaways

  • Macrotasks include setTimeout, setInterval, I/O callbacks, and UI events. Each one is a distinct unit of work.
  • Microtasks include Promise callbacks and queueMicrotask. They have higher priority than macrotasks.
  • After every macrotask, the event loop drains the entire microtask queue before moving to the next macrotask. This is the rule that explains most event loop confusion.
  • Endless microtasks starve macrotasks, timers, and rendering. Break long-running work into batches separated by macrotasks.
  • Use microtasks for fast, follow-up work (like chaining API calls). Use macrotasks when you need to yield control to the browser.
  • Debugging event loop issues: add console.log at strategic points and trace the execution order. You'll spot the pattern.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles