Javascript: Why a Long Chain of Resolved Promises Can Still Freeze the UI

JavaScript developers often hear that Promises are asynchronous, which leads to a common assumption: if code is running through Promises, it shouldn’t block the UI. Yet many developers have been surprised to see a web page become unresponsive while executing a long chain of resolved Promises.

So what’s really happening?

The answer lies in understanding the event loop, microtasks, and how Promise callbacks are scheduled.

The Misconception: Asynchronous Means Non-Blocking

Let’s start with a simple statement:

Promises are asynchronous, but asynchronous does not automatically mean non-blocking.

When a Promise resolves, its .then(), .catch(), or .finally() callbacks don’t execute immediately. Instead, they are placed into the microtask queue, which is processed by the JavaScript event loop.

Many developers assume this scheduling gives the browser opportunities to repaint the screen and handle user interactions. In reality, that’s not always the case

Understanding the Event Loop

A simplified view of the browser’s event loop looks like this:

Run a Task (Macrotask)

Run All Microtasks

Render UI

Process Next Task

Examples of macrotasks include:

  • setTimeout
  • setInterval
  • DOM events
  • Network events

Examples of microtasks include:

  • Promise callbacks (.then())
  • await continuations
  • queueMicrotask()

The critical detail is:

The browser must completely drain the microtask queue before it can render updates or process new user interactions.

A Small Promise Chain

Consider the following code:

Promise.resolve()
  .then(() => console.log("Step 1"))
  .then(() => console.log("Step 2"))
  .then(() => console.log("Step 3"));

The callbacks are executed asynchronously, but they all run as microtasks.

For a few callbacks, this is trivial and unnoticeable.

Problems start appearing when the chain becomes extremely large.

How a Long Promise Chain Freezes the UI

Imagine this code:

let promise = Promise.resolve();

for (let i = 0; i < 1_000_000; i++) {
  promise = promise.then(() => {
    // Some work
  });
}

What happens?

  1. The current script finishes execution.
  2. The event loop starts draining the microtask queue.
  3. Each Promise callback schedules the next one.
  4. The queue remains busy for a long time.
  5. The browser doesn’t get a chance to repaint the page.
  6. User interactions such as clicks, scrolling, and typing are delayed.

From the user’s perspective, the page appears frozen.

Even though the code is technically asynchronous, the browser remains occupied processing microtasks.

Microtask Starvation

This behavior is often referred to as microtask starvation.

Here’s an extreme example:

function endlessLoop() {
  Promise.resolve().then(endlessLoop);
}

endlessLoop();

The sequence never ends because:

  1. A microtask executes.
  2. It immediately schedules another microtask.
  3. The event loop continues processing microtasks.
  4. Rendering never occurs.

As a result, the browser can become completely unresponsive.

Async/Await Has the Same Limitation

Many developers assume async/await solves the problem.

Unfortunately, it doesn’t.

Consider:

async function processItems() {
  for (let i = 0; i < 1_000_000; i++) {
    await Promise.resolve();
  }
}

Although the code looks synchronous, each await resumes execution using the microtask queue.

The browser still has to process each continuation before it gets a chance to repaint.

So a large loop like this can still cause UI responsiveness issues.

Why setTimeout() Doesn’t Cause the Same Problem

Now compare that with:

function process() {
  setTimeout(process, 0);
}

process();

Unlike Promises, setTimeout() schedules a macrotask.

The event loop behaves like this:

Task

Microtasks

Render

Next Task

Since rendering can occur between macrotasks, the browser gets opportunities to:

  • Update the screen
  • Handle mouse events
  • Process keyboard input
  • Run animations

As a result, the page stays responsive.

How to Yield Control Back to the Browser

If you have CPU-intensive work, periodically yield control back to the event loop.

Using setTimeout

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    doWork(items[i]);

    if (i % 1000 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

This gives the browser time to render and respond to user interactions.

Using scheduler.yield()

Where supported:

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    doWork(items[i]);

    if (i % 1000 === 0) {
      await scheduler.yield();
    }
  }
}

This is often cleaner and specifically designed for cooperative scheduling.