← Writing

Tasks, Microtasks, and the Rendering Gap

A small ordering rule explains why a resolved promise can still postpone painting and user input.

On this pageA practical consequence

The event loop does not treat every queued callback alike. A timer callback becomes a task. A promise continuation becomes a microtask. After the current task finishes, the browser drains the microtask queue before it gets a chance to render.

setTimeout(() => console.log('task'), 0);
Promise.resolve().then(() => console.log('microtask'));

console.log('current task');

The order is current task, microtask, then task. That is easy to memorize. The more useful detail is that a microtask can enqueue another microtask. If that chain keeps growing, the browser keeps draining it. A paint opportunity remains out of reach.

A practical consequence#

Moving a large loop into Promise.resolve().then(...) does not make the work friendly to rendering. It changes when the loop begins, but the loop still occupies the main thread. A long chain of short promise callbacks can cause the same problem in smaller pieces because the browser must empty the microtask queue before painting.

When the goal is to let the page update, schedule work across tasks or move computation to a worker. Pick the mechanism based on what must happen between chunks. If the answer is “the browser should be able to paint and process input,” a microtask boundary is the wrong boundary.