JavaScript Event Loop Phases: Macrotasks vs Microtasks

JavaScript is single-threaded for your code, but it still handles timers, promises, DOM events, and network callbacks in a very specific order. This article explains how the event loop phases work, how macrotasks and microtasks are scheduled, and why some callbacks run sooner than others.

Quick answer: Macrotasks are larger units of work like setTimeout callbacks and UI events, while microtasks are higher-priority jobs like promise reactions and queueMicrotask callbacks. After the current call stack finishes, JavaScript drains all microtasks before moving to the next macrotask.

Difficulty: Beginner

You'll understand this better if you know: basic functions, the idea of synchronous vs asynchronous code, and how promises or timers work at a simple level.

1. What Is the Event Loop?

The event loop is the scheduling system that lets JavaScript handle asynchronous work without running your code in parallel. It continuously checks whether the current call stack is empty, then pulls the next job from the appropriate queue and executes it.

When people say “the event loop,” they usually mean the overall mechanism that decides what runs next. The important practical split is between macrotasks and microtasks.

2. Why Macrotasks and Microtasks Matter

Understanding task order helps you predict logs, avoid race conditions, and write code that updates state at the right time. Many confusing bugs come from assuming that a timer, a promise, or a DOM callback will run immediately after the current line.

For example, a promise callback usually runs before setTimeout(..., 0), even though both are asynchronous. That difference matters when you depend on execution order.

3. Basic Syntax or Core Idea

At a high level, JavaScript processes work in this order:

  1. Run the current synchronous code on the call stack.
  2. Drain the microtask queue completely.
  3. Pick the next macrotask and run it.
  4. After that macrotask finishes, drain microtasks again.

Here is the smallest example that shows the difference:

console.log("start");
setTimeout(() => {
  console.log("timeout");
}, 0);
Promise.resolve().then(() => {
  console.log("promise");
});
console.log("end");

This prints start, end, promise, and then timeout. The timer callback is a macrotask, while the promise handler is a microtask.

4. Step-by-Step Examples

Example 1: Synchronous code always runs first

Before any queue is considered, JavaScript finishes the current script execution. That means plain function calls happen before promise handlers or timers.

console.log("A");
console.log("B");
Promise.resolve().then(() => console.log("C"));
console.log("D");

This prints A, B, D, then C. The promise callback waits until the synchronous script completes.

Example 2: Microtasks run before timers

This example shows the most common ordering surprise. Even with a zero-delay timer, the promise callback still runs first.

setTimeout(() => {
  console.log("timer");
}, 0);
Promise.resolve().then(() => {
  console.log("microtask");
});

The output is microtask and then timer. Timers are macrotasks and must wait until the microtask queue is empty.

Example 3: queueMicrotask behaves like a promise reaction

queueMicrotask lets you schedule a microtask directly. It is useful when you want a callback to run after the current stack but before timers or rendering work that happens later.

console.log("1");
queueMicrotask(() => {
  console.log("2");
});
console.log("3");

This prints 1, 3, then 2. A microtask is still deferred, but it runs before the next macrotask.

Example 4: Nested scheduling changes the order

When a callback schedules more work, that new work is added to the correct queue. This can create layered ordering that looks odd until you remember the queue rules.

setTimeout(() => {
  console.log("timeout 1");
  Promise.resolve().then(() => {
    console.log("microtask inside timeout");
  });
}, 0);
setTimeout(() => {
  console.log("timeout 2");
}, 0);

After the first timer finishes, its promise callback is placed in the microtask queue and runs before the second timer. That is why microtask inside timeout appears between the two timer logs.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Assuming setTimeout(0) runs immediately

A zero-delay timer does not mean “run next line.” It means “run in a future macrotask when the event loop gets there.”

Problem: The timer callback is scheduled after the current task and after any pending microtasks, so it will not run before promise handlers.

console.log("before");
setTimeout(() => console.log("timer"), 0);
console.log("after");

Fix: If you need work to happen after the current stack but before the next macrotask, use a microtask.

console.log("before");
queueMicrotask(() => console.log("microtask"));
console.log("after");

The corrected version works because microtasks are drained before the event loop moves to the next macrotask.

Mistake 2: Creating an endless microtask chain

Microtasks are processed until the queue is empty. If each microtask schedules another microtask, the browser or runtime can stay busy and delay rendering or timers.

Problem: This code keeps adding microtasks and can starve the event loop, so other work does not get a chance to run.

function spin() {
  queueMicrotask(spin);
}
spin();

Fix: Break long work into macrotasks or add a limit so the browser can breathe between chunks.

let count = 0;
function workChunk() {
  if (count >= 100) return;
  count++;
  setTimeout(workChunk, 0);
}
workChunk();

The fixed version yields control between chunks, which keeps the event loop responsive.

Mistake 3: Confusing promise callbacks with regular synchronous code

Promise reactions are asynchronous, even when the promise is already resolved. This trips up developers who expect the resolved value to be available immediately.

Problem: The variable is still undefined when it is logged, because the then callback has not run yet.

let value;
Promise.resolve(42).then(result => {
  value = result;
});
console.log(value);

Fix: Read the value inside the microtask chain, or use async and await so the timing is explicit.

async function showValue() {
  const value = await Promise.resolve(42);
  console.log(value);
}
showValue();

The corrected version works because the log happens after the microtask that resolves the promise has completed.

7. Best Practices

Practice 1: Use microtasks for short follow-up work

If the follow-up work is tiny and must happen before the next macrotask, queueMicrotask is a good fit. It keeps the ordering explicit without pretending the work is synchronous.

function updateCache(value) {
  queueMicrotask(() => {
    console.log("cache updated for", value);
  });
}

This keeps the main call readable and reserves the microtask for a quick follow-up action.

Practice 2: Use macrotasks to yield during heavy work

For long loops or chunked processing, macrotasks are often better because they let the browser handle input and rendering between chunks.

const items = [1, 2, 3, 4, 5];
let index = 0;
function processNext() {
  if (index >= items.length) return;
  console.log(items[index++]);
  setTimeout(processNext, 0);
}
processNext();

This approach prevents one large job from blocking the event loop for too long.

Practice 3: Keep queue-sensitive logic simple and predictable

When execution order matters, reduce the number of nested callbacks. Clearer scheduling is easier to debug than deeply interleaved microtasks and timers.

function loadThenRender() {
  return Promise.resolve()
    .then(() => "data")
    .then(data => {
      console.log(data);
    });
}

Simple chains are easier to reason about than mixing unrelated timers and promise handlers in the same flow.

8. Limitations and Edge Cases

9. Practical Mini Project

Let’s build a tiny logger that records the order in which different kinds of work run. This is useful for learning and for debugging async ordering in real projects.

const log = [];
function record(label) {
  log.push(label);
}
record("sync 1");
queueMicrotask(() => record("microtask"));
setTimeout(() => record("macrotask"), 0);
record("sync 2");
setTimeout(() => {
  console.log(log);
}, 10);

This example collects the order into an array and prints it later. A typical result is ["sync 1", "sync 2", "microtask", "macrotask"]. It shows that synchronous work finishes first, then the microtask, then the timer callback.

10. Key Points

11. Practice Exercise

Expected output: The synchronous logs appear first, then the microtasks in the order they were scheduled, and finally the timer.

Hint: Remember that all microtasks drain before the event loop moves to the next macrotask.

console.log("sync");
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));
setTimeout(() => console.log("timeout"), 0);

12. Final Summary

The event loop is the system that decides when JavaScript runs queued work. The key idea is simple: synchronous code runs first, then the microtask queue is drained, and only after that does the next macrotask run. This is why promise handlers and queueMicrotask callbacks usually happen before timers.

Once you internalize the difference between macrotasks and microtasks, async ordering becomes much easier to reason about. You will be better at debugging unexpected output, designing responsive UI code, and choosing the right scheduling tool for each job.

If you want to go further, next study the browser render cycle and how promises, timers, and input events interact with it in real applications.