JavaScript Event Loop, Task Queue, and Microtask Queue

The JavaScript event loop is the mechanism that lets JavaScript handle asynchronous work without blocking the main thread. If you understand task queues and microtask queues, you can predict the order of callbacks, promises, timers, and DOM updates with much more confidence.

Quick answer: JavaScript runs one piece of code at a time on the call stack. When that stack is empty, the event loop takes work from the task queue, but it always drains the microtask queue first. That is why promise callbacks usually run before timer callbacks.

Difficulty: Intermediate

You'll understand this better if you know: basic functions, callbacks, promises, and how synchronous code runs from top to bottom.

1. What Is the Event Loop, Task Queue, and Microtask Queue?

The event loop is JavaScript's scheduling system. It decides when queued work can run after the current synchronous code finishes.

In everyday use, people often say macrotask instead of task. The important idea is not the label, but that tasks and microtasks are processed at different times.

2. Why the Event Loop Matters

The event loop is what makes asynchronous JavaScript feel responsive. It allows timers, network responses, input events, and promise callbacks to happen without freezing the page or blocking other work.

It matters whenever you need to predict ordering. For example, if you mix Promise, setTimeout(), and synchronous code, the output order may surprise beginners unless they know how the queues are drained.

You also need this model to debug issues such as UI updates appearing late, timer callbacks not running when expected, or promise handlers running earlier than a timer.

3. Basic Syntax or Core Idea

How the queues work together

JavaScript executes synchronous code first. When the current stack is empty, the event loop checks the microtask queue and runs every microtask until it is empty. Only then does it take the next task from the task queue.

console.log("start"); // synchronous

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

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

console.log("end");

This code prints start, end, microtask, then task. The timer callback is a task, while the promise handler is a microtask.

Queue priority at a glance

A simplified mental model is:

while (stack is not empty) {
  // keep running synchronous code
}

run all microtasks
run one task
repeat

This is not exact engine source code, but it is accurate enough to predict most real-world ordering.

4. Step-by-Step Examples

Example 1: Synchronous code before everything else

Synchronous statements run immediately in the order they appear. Neither a promise callback nor a timer can interrupt them.

console.log("A");

setTimeout(() => console.log("B"), 0);

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

console.log("D");

Output order: A, D, C, B. The timer waits for a task turn, but the promise callback runs in the microtask queue before the next task.

Example 2: Multiple microtasks run before the next task

Microtasks are drained completely once the current synchronous work finishes. If one microtask schedules another microtask, the new one also runs before any task.

Promise.resolve()
  .then(() => {
    console.log("first microtask");
    return Promise.resolve();
  })
  .then(() => {
    console.log("second microtask");
  });

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

Even though the timer was scheduled immediately, both promise callbacks run first because they stay inside the microtask queue.

Example 3: queueMicrotask() creates a microtask directly

When you want to schedule work at microtask priority without creating a promise, use queueMicrotask().

console.log("before");

queueMicrotask(() => {
  console.log("microtask");
});

console.log("after");

This prints before, after, then microtask. The callback runs after the stack clears, but before the next task.

Example 4: A timer callback can schedule more microtasks

Each task can create new microtasks. Those microtasks run before the event loop moves on to another task.

setTimeout(() => {
  console.log("task start");

  Promise.resolve().then(() => {
    console.log("microtask inside task");
  });

  console.log("task end");
}, 0);

The task logs its synchronous lines first. After that task finishes, the promise reaction runs before the next timer or event callback.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Assuming setTimeout(0) runs immediately

A zero-delay timer does not mean "run now". It means "queue a task as soon as the event loop gets a chance."

Problem: The timer still waits behind synchronous code and all pending microtasks, so code that expects an immediate callback often logs in the wrong order.

console.log("start");

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

console.log("still synchronous");

Fix: Use setTimeout() only when you truly want a task turn later. If you need work after the current call but before the next task, use queueMicrotask() or a promise reaction.

console.log("start");

queueMicrotask(() => {
  console.log("microtask");
});

console.log("still synchronous");

The corrected version works because microtasks run before the next task.

Mistake 2: Forgetting that promise callbacks are microtasks

Promise reactions do not wait for timers. They are scheduled as microtasks, so they usually run earlier than beginners expect.

Problem: Developers often assume a timer scheduled first will log first, but a promise callback can still run sooner because the microtask queue has higher priority.

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

Promise.resolve().then(() => console.log("promise"));

Fix: Treat promise handlers as microtasks and timers as tasks. If you need to preserve an order, schedule them with the same kind of queue or chain them explicitly.

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

The corrected version makes the dependency explicit instead of relying on queue priority guesswork.

Mistake 3: Creating an endless microtask loop

Microtasks are drained before the browser moves on to rendering or the next task. If you keep adding new microtasks inside microtasks, you can starve the event loop.

Problem: A self-perpetuating microtask chain can prevent timers, input events, and rendering from happening, making the page appear frozen.

function loop() {
  queueMicrotask(loop);
}

loop();

Fix: Break long or repeated work into tasks occasionally so the browser can render and respond to input.

let count = 0;

function workChunk() {
  for (let i = 0; i < 1000; i++) {
    count++;
  }

  if (count < 5000) {
    setTimeout(workChunk, 0);
  }
}

workChunk();

The corrected version yields back to the task queue, which gives rendering and input a chance to happen.

7. Best Practices

Use microtasks for post-sync follow-up work

If you need to run code right after the current stack finishes, but before timers and other tasks, microtasks are the right fit.

queueMicrotask(() => {
  // update derived state after the current function returns
});

This keeps related work tightly ordered without waiting for an unnecessary timer delay.

Use tasks to yield to the browser

When the goal is responsiveness, a task boundary can be useful because it gives the browser a chance to render and handle input.

function processLargeList(items) {
  let index = 0;

  function step() {
    const end = Math.min(index + 100, items.length);
    while (index < end) {
      // process items[index]
      index++;
    }

    if (index < items.length) {
      setTimeout(step, 0);
    }
  }

  step();
}

This approach avoids locking up the main thread for too long.

Keep queue choice consistent within one workflow

Mixing tasks and microtasks inside a small flow can make ordering hard to reason about. Pick one queue purpose and stick to it.

Promise.resolve()
  .then(() => updateState())
  .then(() => renderPreview());

This is clearer than scattering related steps across unrelated timers, because the chain documents the order directly.

8. Limitations and Edge Cases

A common "not working" complaint is that a UI change does not appear until after a long batch of microtasks. That usually means the browser never got a chance to render between queued microtasks and the next task.

9. Practical Mini Project

Here is a small browser example that shows how a status update, a promise-based follow-up, and a timer interact. The code logs the order and updates the page so you can observe the behavior directly.

<button id="run">Run demo</button>
<p id="output"></p>

<script>
  const output = document.getElementById("output");
  const runButton = document.getElementById("run");

  function log(message) {
    output.textContent += message + "\n";
  }

  runButton.addEventListener("click", () => {
    output.textContent = "";
    log("1. click handler start");

    Promise.resolve().then(() => log("2. microtask"));

    setTimeout(() => log("3. task"), 0);

    log("4. click handler end");
  });
</script>

When you click the button, the click handler runs first because it is the current task. Then the promise callback runs as a microtask, and the timer callback runs later as the next task. This example is small, but it mirrors the exact ordering questions developers face in real UI code.

10. Key Points

11. Practice Exercise

Expected output: synchronous logs first, then all microtasks in the order they were queued, then timer callbacks.

Hint: Think in three layers: current stack, microtasks, then tasks.

console.log("A");

queueMicrotask(() => console.log("B"));

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

setTimeout(() => console.log("D"), 0);

console.log("E");

Solution:

// Output order:
// A
// E
// B
// C
// D

12. Final Summary

The JavaScript event loop is the system that keeps asynchronous code moving without blocking synchronous execution. It lets JavaScript finish the current stack, then process microtasks, and only then move on to the next task.

That ordering is the key to understanding why promise callbacks often run before timers, why queueMicrotask() is useful for immediate follow-up work, and why too many microtasks can delay rendering. Once you can reason about the stack, microtasks, and tasks together, asynchronous JavaScript becomes much easier to debug and design.

If you want to go further, study browser rendering timing, promise chaining, and how Node.js event loop phases differ from the browser model.