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:
setTimeoutsetInterval- DOM events
- Network events
Examples of microtasks include:
- Promise callbacks (
.then()) awaitcontinuationsqueueMicrotask()
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?
- The current script finishes execution.
- The event loop starts draining the microtask queue.
- Each Promise callback schedules the next one.
- The queue remains busy for a long time.
- The browser doesn’t get a chance to repaint the page.
- 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:
- A microtask executes.
- It immediately schedules another microtask.
- The event loop continues processing microtasks.
- 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.