JavaScript Microtasks vs Macrotasks: Execution Order

JavaScript's single-threaded execution model forces all code to run sequentially on one call stack. When you write asynchronous code, you're not actually running multiple things simultaneously—you're...

Key Insights

  • JavaScript processes tasks in a strict priority order: synchronous code executes first, then all microtasks drain completely, and only then does a single macrotask execute before the cycle repeats.
  • Promises, async/await, and queueMicrotask() use the microtask queue and always execute before setTimeout, setInterval, and I/O callbacks, even when timers are set to 0ms.
  • Understanding task queues prevents subtle bugs in async code, especially when coordinating animations, state updates, or ensuring operations complete before rendering.

The JavaScript Event Loop

JavaScript’s single-threaded execution model forces all code to run sequentially on one call stack. When you write asynchronous code, you’re not actually running multiple things simultaneously—you’re scheduling callbacks to execute later. The event loop manages this scheduling, but here’s what most developers miss: not all “later” is created equal.

The event loop maintains multiple queues for different types of asynchronous operations. Understanding which queue your code enters determines exactly when it executes. Get this wrong, and you’ll face race conditions, visual glitches, and bugs that seem to appear randomly across different browsers or load conditions.

Understanding Macrotasks (Task Queue)

Macrotasks represent the “regular” task queue that most developers learn about first. These are discrete units of work that the browser schedules for execution. Common macrotasks include:

  • setTimeout and setInterval
  • setImmediate (Node.js)
  • I/O operations
  • UI rendering
  • User interaction events (clicks, keyboard input)

Here’s the critical misconception: setTimeout with a 0ms delay doesn’t execute immediately.

console.log('Start');

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

console.log('End');

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

Even with zero delay, setTimeout schedules a macrotask that executes only after the current script completes and all microtasks process. The “0ms” is a minimum delay, not a guarantee of immediate execution.

Understanding Microtasks (Job Queue)

Microtasks form a separate, higher-priority queue. The event loop completely drains this queue before moving to the next macrotask. Common microtasks include:

  • Promise callbacks (.then, .catch, .finally)
  • queueMicrotask()
  • MutationObserver callbacks
  • process.nextTick() in Node.js (highest priority)

Promises always use microtasks for their resolution handlers:

console.log('Start');

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

console.log('End');

// Output:
// Start
// End
// Promise

The promise callback executes immediately after the current synchronous code, before any macrotasks get their turn. This happens even though we called Promise.resolve() after the first console.log.

Execution Order Rules

The event loop follows a strict execution pattern:

  1. Execute synchronous code on the call stack until empty
  2. Process all microtasks until the microtask queue is empty
  3. Execute one macrotask from the macrotask queue
  4. Repeat from step 2

The critical detail: microtasks can queue additional microtasks, and all of them execute before the next macrotask. This creates a priority system where microtasks always cut in line.

console.log('1: Sync start');

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

Promise.resolve().then(() => {
  console.log('3: Promise 1');
}).then(() => {
  console.log('4: Promise 2');
});

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

Promise.resolve().then(() => {
  console.log('6: Promise 3');
});

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

// Output:
// 1: Sync start
// 7: Sync end
// 3: Promise 1
// 6: Promise 3
// 4: Promise 2
// 2: Timeout 1
// 5: Timeout 2

Notice how all promise callbacks execute before any setTimeout callbacks, regardless of when they were scheduled. The chained promise (Promise 2) also executes before the timeouts because it queues as a microtask.

Practical Scenarios and Gotchas

Real applications mix these task types in ways that create counter-intuitive behavior. Consider this scenario where promises and timers interact:

console.log('Start');

setTimeout(() => {
  console.log('Timeout 1');
  
  Promise.resolve().then(() => {
    console.log('Promise inside Timeout 1');
  });
}, 0);

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

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

console.log('End');

// Output:
// Start
// End
// Promise 1
// Promise 2
// Timeout 1
// Promise inside Timeout 1
// Timeout 2
// Timeout inside Promise 1

This demonstrates several important behaviors:

  • Initial promises execute before any timeouts
  • When a timeout executes, any promises it creates become microtasks that execute before the next timeout
  • Timeouts scheduled from within promises go to the back of the macrotask queue

The async/await syntax uses promises under the hood, so it follows microtask rules:

async function example() {
  console.log('Async function start');
  
  await Promise.resolve();
  
  console.log('After await');
}

console.log('Script start');

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

example();

console.log('Script end');

// Output:
// Script start
// Async function start
// Script end
// After await
// Timeout

The code after await executes as a microtask, so it runs before the timeout even though the timeout was scheduled first.

Debugging and Best Practices

Predicting execution order becomes easier when you follow these principles:

Use microtasks for critical sequencing. If operation B must happen immediately after operation A completes but before any other async work, use promises or queueMicrotask().

Use macrotasks for deferring work. When you want to break up long-running operations or ensure the browser has time to render, setTimeout is appropriate.

Avoid microtask loops. Since microtasks drain completely, a microtask that queues another microtask indefinitely will block all macrotasks:

// Don't do this - blocks rendering and I/O
function infiniteMicrotasks() {
  queueMicrotask(() => {
    console.log('This will block everything else');
    infiniteMicrotasks();
  });
}

The queueMicrotask() API provides explicit control over microtask scheduling, useful when you need to guarantee execution before rendering:

function updateDOM() {
  const element = document.getElementById('status');
  element.textContent = 'Processing...';
  
  queueMicrotask(() => {
    // This runs before the browser renders
    // Guaranteed to execute before the user sees "Processing..."
    performValidation();
    element.textContent = 'Complete';
  });
}

This ensures both DOM updates happen in the same render frame, preventing visual flicker.

Debug with labeled functions. When debugging complex async flows, use named functions instead of arrow functions:

setTimeout(function timeoutHandler() {
  console.log('Easier to identify in stack traces');
}, 0);

Promise.resolve().then(function promiseHandler() {
  console.log('Named handlers help debugging');
});

Named functions appear clearly in browser DevTools, making it easier to trace execution order.

Conclusion and Key Takeaways

The microtask vs macrotask distinction fundamentally affects how asynchronous JavaScript executes. Microtasks always execute before the next macrotask, creating a priority system that determines the order of all async operations.

This matters most when:

  • Coordinating state updates that must complete before rendering
  • Sequencing async operations where timing is critical
  • Debugging race conditions in complex async code
  • Optimizing performance by batching updates appropriately

Remember: synchronous code executes first, then the microtask queue drains completely, then one macrotask executes, and the cycle repeats. Promises and async/await use microtasks. Timers and I/O use macrotasks.

Master this execution model, and you’ll write more predictable async code, debug issues faster, and avoid subtle timing bugs that plague developers who treat all async operations as equivalent. The event loop’s priority system isn’t just theoretical—it’s the foundation of reliable asynchronous JavaScript.

Liked this? There's more.

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