Learnitweb

Understanding the Microtask Queue and Why It Can Still Freeze the Browser

In the previous tutorials, you learned that modern browsers use a combination of task queues, an event loop, and a rendering pipeline that typically runs every sixteen milliseconds. You also learned that if a task takes longer than this window, the browser is unable to update the user interface, which leads to visible freezing, unresponsive buttons, and delayed user interactions.

You also saw that splitting a long-running task into smaller chunks and scheduling those chunks using regular tasks, such as setTimeout, allows the browser to remain responsive. This happens because the browser gets a chance to render and handle user input between tasks.

However, things become slightly more complicated once we introduce another important concept: microtasks.

In this tutorial, we will explore what microtasks are, how they differ from regular tasks, and why using them incorrectly can still freeze your application even when the work is broken into smaller pieces.

Understanding Microtasks in the Browser

In addition to regular task queues, modern browsers also maintain a microtask queue. This queue is specifically designed for a special category of operations that must be executed as soon as possible, right after the current task finishes.

Microtasks are commonly created in the following ways:

  • When a Promise is fulfilled or rejected and a .then() or .catch() handler is attached
  • When the queueMicrotask() function is explicitly called

Every time a microtask is scheduled, it is placed into the microtask queue rather than the regular task queue.

The most important rule to understand is this:

The browser must fully drain the microtask queue before it is allowed to render or process any other task.

This means that if microtasks keep getting added continuously, the browser cannot repaint the screen, cannot handle user input, and cannot run other scheduled tasks.

How the Microtask Queue Fits Into the Execution Flow

When a task finishes executing, the browser follows this general process:

  1. Check whether there are any microtasks waiting in the microtask queue.
  2. Execute all microtasks, one by one, until the queue is completely empty.
  3. Only after the microtask queue is empty can the browser proceed to rendering or move on to the next task from the regular task queues.

If new microtasks are added while the microtask queue is being processed, they must also be executed immediately, before anything else can happen.

This behavior makes microtasks extremely powerful, but also potentially dangerous when misused.

Why Microtasks Still Block the UI

The key reason lies in how microtasks are scheduled and executed.

Microtasks are designed to run before anything else, including rendering and handling user input. When one microtask finishes, the browser immediately checks whether there are more microtasks waiting. If there are, it executes them immediately.

In case each microtask schedules another microtask. This creates a continuous chain of microtasks with no opportunity for the browser to pause and perform rendering.

As a result:

  • The browser cannot repaint the screen.
  • User input is ignored.
  • The UI remains frozen until all microtasks are completed.

Even though the work is split into smaller pieces, the browser never gets a chance to breathe.

Comparing Regular Tasks and Microtasks

The difference becomes much clearer when we compare their behavior conceptually.

Regular tasks using setTimeout

  • Each task is placed into the task queue.
  • After each task, the browser can render and handle input.
  • The page remains responsive.
  • Progress updates are visible.

Microtasks using queueMicrotask or Promises

  • All microtasks must complete before rendering.
  • New microtasks can continuously delay rendering.
  • The browser becomes unresponsive during execution.
  • The behavior resembles a single long-running task.

Conceptual Timeline Representation

Regular Tasks:
[ Task ] → Render → [ Task ] → Render → [ Task ] → Render

Microtasks:
[ Microtask → Microtask → Microtask → Microtask ]
(No rendering until all microtasks finish)

Scenario: Updating Application State Before Rendering

Imagine a situation where your application needs to perform small internal updates immediately after a task completes, but before the browser renders anything.

For example, suppose you are tracking application state and want to ensure that:

  • Internal state updates are always consistent
  • Dependent logic runs immediately after a state change
  • Rendering happens only after all related updates are complete

This is a perfect use case for microtasks.

Key Idea Behind This Example

We will simulate the following sequence:

  1. A user clicks a button.
  2. A synchronous operation updates some state.
  3. A microtask runs immediately afterward to perform follow-up logic.
  4. Only after all microtasks finish does the browser render the UI.

This will clearly demonstrate that microtasks always run before rendering, even if they are scheduled after synchronous code.

Complete Working Example (Single File)

You can copy and run this entire file in your browser.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Microtask Example</title>

  <style>
    body {
      font-family: Arial, sans-serif;
      padding: 20px;
      line-height: 1.6;
    }

    button {
      padding: 10px 16px;
      margin-right: 10px;
      cursor: pointer;
    }

    #log {
      margin-top: 20px;
      white-space: pre-line;
      background: #f4f4f4;
      padding: 12px;
    }
  </style>
</head>

<body>

  <h2>Microtask Execution Example</h2>

  <button onclick="runExample()">Run Example</button>

  <div id="log"></div>

  <script>
    function log(message) {
      const logDiv = document.getElementById("log");
      logDiv.textContent += message + "\n";
    }

    function runExample() {
      log("1. Button clicked");

      setTimeout(() => {
        log("5. setTimeout callback executed");
      }, 0);

      Promise.resolve().then(() => {
        log("3. Microtask executed (Promise.then)");
      });

      queueMicrotask(() => {
        log("4. Microtask executed (queueMicrotask)");
      });

      log("2. Synchronous code finished");
    }
  </script>

</body>
</html>

Expected Output Order

When you click the button, the output will appear in the following order:

1. Button clicked
2. Synchronous code finished
3. Microtask executed (Promise.then)
4. Microtask executed (queueMicrotask)
5. setTimeout callback executed

Why This Order Happens

To understand this, let us carefully walk through the execution step by step.

Step 1: Synchronous Code Execution

When the button is clicked, the JavaScript engine immediately begins executing the function runExample.
The first and last log statements inside this function execute synchronously.

At this point, the call stack is busy, and nothing else can run.

Step 2: Microtasks Are Scheduled

During the execution of the function:

  • Promise.resolve().then(...) schedules a microtask.
  • queueMicrotask(...) schedules another microtask.

These microtasks do not execute immediately. They wait until the current call stack becomes empty.

Step 3: Call Stack Becomes Empty

Once the synchronous code finishes, the JavaScript engine checks the microtask queue.

At this moment:

  • Both microtasks are waiting.
  • They are executed before anything else.

This includes rendering and any setTimeout callbacks.

Step 4: Regular Tasks Are Executed Last

Only after all microtasks have completed does the browser move on to the regular task queue.

This is when the setTimeout callback finally runs.

Why Microtasks Exist

Microtasks are designed for situations where:

  • You need to perform follow-up logic immediately after a state change
  • You want predictable ordering without waiting for the next rendering cycle
  • You want to guarantee consistency before the UI updates

Promises internally rely on microtasks to ensure predictable and reliable behavior.