Task Queue vs Microtask Queue in JavaScript: Execution Priority Explained

TLDR: The microtask queue has higher priority than the task (macrotask) queue, meaning Promise callbacks always execute before setTimeout callbacks, even if the timer was scheduled first.

JavaScript's single‑threaded nature relies on an event loop to manage asynchronous operations. In the leonardomso/33-js-concepts repository, the event loop implementation is dissected to reveal how two distinct queues—the Task Queue (macrotask) and the Microtask Queue—determine the exact order of callback execution.

What Is the Task Queue (Macrotask)?

The Task Queue, also called the macrotask queue, holds callbacks from asynchronous Web APIs that can be deferred until the next event loop tick. According to the source code analysis in docs/concepts/event-loop.mdx, this queue contains lower‑priority work such as setTimeout, setInterval, I/O operations, UI events, and setImmediate (Node.js).

When the call stack empties, the event loop picks exactly one task from this queue, pushes it onto the stack for execution, and then checks the microtask queue again.

What Is the Microtask Queue?

The Microtask Queue holds high‑priority callbacks that must execute immediately after the current synchronous script completes. As documented in the repository's test suite at tests/functions-execution/event-loop/event-loop.test.js, this queue includes:

  • Promise.then, .catch, and .finally handlers
  • queueMicrotask() callbacks
  • MutationObserver notifications
  • Continuations after await expressions

Unlike the task queue, the event loop drains the entire microtask queue before touching any macrotask. This means if a microtask schedules another microtask, that new callback also runs before any pending macrotask.

How the Event Loop Processes Both Queues

The event loop follows a strict three‑step algorithm as implemented in JavaScript engines:

  1. Execute all synchronous statements on the call stack until it is empty.
  2. Drain the entire microtask queue, executing every pending microtask and any new microtasks they enqueue.
  3. Process one macrotask from the task queue, then return to step 1.

This behavior is verified in tests/functions-execution/event-loop/event-loop.test.js by the test case "Promises BEFORE setTimeout (microtasks before macrotasks)" at lines 89‑112, which confirms that promise resolution always precedes timer callbacks regardless of scheduling order.

Code Examples from the 33-js-concepts Repository

Promise vs setTimeout Ordering

console.log('A');                 // sync
setTimeout(() => console.log('B'), 0); // macrotask
Promise.resolve().then(() => console.log('C')); // microtask
console.log('D');                 // sync
// Output: A, D, C, B

Even though setTimeout is scheduled before the Promise, the microtask queue drains first. The test "Promises BEFORE setTimeout" in event-loop.test.js (lines 89‑112) validates this exact output sequence.

Nested Microtasks Drain Before Any Macrotask

console.log('Start');

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

setTimeout(() => console.log('Task'), 0);
console.log('End');

// Output: Start, End, First microtask, Second microtask, Task

The second microtask, created inside the first, still executes before the setTimeout callback. This demonstrates the "drain entire microtask queue before any macrotask" behavior tested at lines 14‑40 in tests/functions-execution/event-loop/event-loop.test.js.

queueMicrotask vs Promise.then

Promise.resolve().then(() => console.log('promise 1'));
queueMicrotask(() => console.log('queueMicrotask 1'));
Promise.resolve().then(() => console.log('promise 2'));
queueMicrotask(() => console.log('queueMicrotask 2'));
/* Output:
   promise 1
   queueMicrotask 1
   promise 2
   queueMicrotask 2
*/

Both APIs enqueue callbacks to the same microtask queue, preserving FIFO order. The repository's test "interleave Promise.resolve() and queueMicrotask in order" at lines 77‑89 confirms this equivalence.

Microtask Starvation (Anti-pattern)

function endlessMicrotasks() {
  Promise.resolve().then(() => {
    console.log('microtask');
    endlessMicrotasks(); // schedules another microtask immediately
  });
}

setTimeout(() => console.log('timeout'), 0);
endlessMicrotasks(); // ⚠️ never lets the timeout run

Because the microtask queue never empties, the macrotask (timeout) never executes, potentially freezing the UI. The documentation in docs/concepts/event-loop.mdx (lines 95‑102) explicitly warns against this microtask starvation scenario.

Why JavaScript Maintains Two Separate Queues

The dual‑queue architecture serves distinct execution requirements:

  • Microtasks handle operations requiring immediate consistency, such as promise resolution and DOM mutations, ensuring state is settled before the next frame renders.
  • Macrotasks handle deferrable work like timers and I/O that can safely wait until the next event loop iteration.

As noted in docs/concepts/event-loop.mdx, this separation prevents long‑running synchronous blocks while ensuring critical asynchronous updates (like promise resolutions) occur as soon as possible without waiting for slower macrotasks.

Summary

  • The microtask queue has higher priority than the task (macrotask) queue.
  • The event loop drains the entire microtask queue before processing a single macrotask.
  • Promises, queueMicrotask, and await continuations enqueue microtasks.
  • setTimeout, setInterval, and I/O callbacks enqueue macrotasks.
  • Recursive microtask scheduling can cause starvation, blocking macrotasks indefinitely.

Frequently Asked Questions

Do microtasks always run before macrotasks?

Yes. According to the test suite in tests/functions-execution/event-loop/event-loop.test.js, the event loop always empties the microtask queue completely before executing the next macrotask. This means a Promise.then() callback will always execute before a setTimeout(fn, 0) callback, regardless of which was scheduled first.

Can microtasks block the browser?

Yes. If you recursively schedule microtasks without yielding, such as calling Promise.resolve().then() inside itself indefinitely, the microtask queue never empties. This prevents the event loop from reaching the task queue or rendering frames, causing UI freezing—a scenario called microtask starvation documented in docs/concepts/event-loop.mdx.

Is queueMicrotask identical to Promise.resolve().then()?

Functionally, both enqueue callbacks into the same microtask queue with identical priority. The repository's test "interleave Promise.resolve() and queueMicrotask in order" confirms they execute in FIFO order relative to each other. However, queueMicrotask is more explicit and avoids creating unnecessary Promise objects.

Where does the await keyword fit in this system?

The await keyword pauses async function execution and schedules the continuation as a microtask when the awaited Promise settles. This places await resumptions in the same high‑priority queue as Promise.then() callbacks, ensuring they execute before any pending macrotasks like timers or I/O events.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →