Tags: web-dev concept

Tasks and Microtasks

Date: 2026-08-16


Two queues with different priorities. Microtasks drain completely after every single task, before the browser is allowed to render — which is why promise callbacks jump ahead of timers, and why a self-queueing microtask can freeze a page that a loop of timers wouldn’t.


What they are

  • A task (sometimes “macrotask”) — a discrete unit of work from the task queue: a timer callback, a DOM event handler, a network response, a parsed chunk of HTML
  • A microtask — a smaller unit with higher priority: a promise callback (.then, await continuation — Promises and Async Await), queueMicrotask(), a MutationObserver callback

The rule

1  take ONE task, run it to completion
2  drain the ENTIRE microtask queue
       (including microtasks queued by other microtasks)
3  render, if it's time for a frame
4  back to 1

One task, then all microtasks. Not one microtask — all of them, including any queued during the draining.

Worked

console.log('1');
 
setTimeout(() => console.log('2'), 0);        // task
 
Promise.resolve().then(() => console.log('3')); // microtask
 
queueMicrotask(() => console.log('4'));         // microtask
 
console.log('5');
1        synchronous
5        synchronous
3        microtask — drains before the next task
4        microtask
2        task — runs last, despite the 0ms delay

The setTimeout with zero delay runs after both promise callbacks, because the entire microtask queue is drained before the loop takes another task.

In plain terms: promises are queued in the fast lane. Timers are queued in the slow lane. Everything in the fast lane goes before anything else in the slow lane, no matter what delay you asked for.

Why this design

Microtasks exist so that a sequence of promise resolutions completes as one logical unit, without the DOM being rendered or a click handler firing halfway through. It guarantees a consistent view of the world across a chain of .thens.

That guarantee is also the hazard.

The microtask starvation trap

// LOCKS THE PAGE COMPLETELY
function spin() { Promise.resolve().then(spin); }
spin();

Each microtask queues another, and step 2 says drain the entire queue. It never empties, so the loop never reaches rendering. The page is frozen and cannot be recovered.

Compare:

// SLOW BUT SURVIVABLE
function spin() { setTimeout(spin, 0); }
spin();

This queues a task, so the loop completes step 2, renders at step 3, and comes back. The page stays interactive, if busy.

The difference matters practically: recursive promise chains are a natural way to write polling or batched processing, and they’re the version that can hang the tab.

Where it bites

  • await in a loop creates a microtask per iteration. Ten thousand iterations drains as one uninterrupted block, so nothing renders in the middle — the page appears frozen even though nothing is technically blocking
  • MutationObserver callbacks are microtasks. A callback that mutates the DOM can trigger itself, and the same starvation applies
  • Splitting work with promises doesn’t yield to rendering. await Promise.resolve() between chunks feels like yielding and isn’t — the browser still can’t paint. To genuinely yield you need a task: setTimeout(…, 0), scheduler.yield() where available, or requestAnimationFrame for visual work. See Long Tasks and Blocking

Rendering sits between tasks

Worth stating explicitly because it explains a common bug:

el.style.opacity = '0';
doExpensiveWorkFor500ms();
el.style.opacity = '1';
// the user never sees opacity 0 — no frame was rendered in between

Style changes don’t appear when you set them. They appear when the browser next renders, which is after the current task and all microtasks. If you need a paint in between, you need to end the task — see The Rendering Pipeline and The Event Loop.