awesome-everything RU
↑ Back to the climb

Browser & Frontend Runtime

Tasks, microtasks, and scheduler.yield()

Crux The HTML spec''''s processing model — task sources, the 5-step iteration, how input lands on the loop, and how to yield without breaking rendering.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 16 min

You write await Promise.resolve() to “yield” inside a long loop. The browser still freezes. You try setTimeout(fn, 0). Now it flickers weirdly. The problem is not your code — it is not knowing which queue each primitive lands in.

The HTML spec model: task sources and task queues

The HTML standard (§ event loops) defines an event loop as a thread with a set of task queues. Each task is associated with a task source — the kind of work that produced it (DOM manipulation, user interaction, networking, history traversal). Each task source has its own queue, and the user agent picks one queue per iteration, dequeues the oldest task, and runs it to completion. There is no preemption. When the task ends, the agent runs the microtask checkpoint, then optionally the render step, then loops back.

Microtasks vs tasks: the decisive rule

A microtask is queued by: Promise.then, Promise.catch, Promise.finally, queueMicrotask, MutationObserver, and await (sugar over Promise.then).

A task is queued by: setTimeout, setInterval, MessageChannel.postMessage, network responses, dispatched DOM events, parsed HTML chunks.

The decisive operational rule: microtasks drain to empty between every task and every frame. If a microtask schedules another microtask, both run before the loop moves on. This is why Promise.resolve().then(self) in a tight chain hangs the page: the microtask queue is never empty, the loop never reaches step 4, and the browser never paints.

What goes where
Promise.then / queueMicrotask
microtask queue
setTimeout / setInterval
task queue (timer source)
MessageChannel.postMessage
task queue (messaging source)
DOM click event
task queue (user-interaction source)
MutationObserver callback
microtask queue
scheduler.yield()
task queue (resumes as next task)

queueMicrotask vs Promise.resolve().then vs MessageChannel

Three ways to “schedule something soon,” each with different semantics:

  • queueMicrotask(fn) — queues a microtask directly. Runs after the current task, before the next. No rendering between.
  • Promise.resolve().then(fn) — identical timing to queueMicrotask, but allocates a Promise, a closure, and a chain entry. Slightly slower, otherwise identical. Does not yield to the renderer.
  • new MessageChannel().port1.postMessage(...) — queues a task. Runs after the current task, after microtasks drain, possibly after a frame. Does yield to the renderer.

The pattern matters when you want to yield to rendering: microtasks do not; MessageChannel does. Modern code uses scheduler.yield() (Chrome 115+) for explicit yield points.

Where requestAnimationFrame fits

rAF callbacks run in step 4 of the loop, immediately before style/layout/paint. The browser only enters step 4 when it has decided a frame is due — typically every 16.67 ms at 60 Hz. If a long task missed several frame deadlines, the browser does not queue up multiple rAF callbacks; it runs the rAF set once. This is why a long task always shows as exactly one frame drop: rAF is driven by the render budget, not a timer.

Where input fits

Input events (mousemove, click, keydown, touchstart) are queued as tasks by the browser’s input thread (a separate OS thread). The main thread pulls them in step 1 like any other task. There is no preemption: if your JS task runs for 80 ms when a click arrives at the 20 ms mark, the click waits 60 ms before its handler can run. INP measures exactly this latency: when did the user click, when did the next paint reflect the result. INP < 200 ms p75 is “good.” Hitting that requires that no single task on the loop exceeds about 50 ms.

How a DOM event becomes a task

When the user clicks, the browser’s input thread records the hit-test target and queues a single task on the main thread’s user-interaction task source. When the main thread pulls that task, it runs the full event dispatch in one shot: capture phase down to the target, then the target itself, then bubble phase back up. Every matching listener along that path runs synchronously inside this one task — there is no yield point between them. This is why a slow listener on a high ancestor (a mousemove handler on document) taxes every descendant interaction. The passive: true listener option matters for scroll, wheel, and touch: it promises the listener will not call preventDefault(), letting the browser start compositor scrolling without waiting for the dispatch task to finish.

Yielding inside a long task

If you must do 200 ms of work, break it into ≤50 ms chunks separated by yield points so input can interleave. Three techniques:

  1. setTimeout(fn, 0) — queues a task in ~4 ms (clamped); coarse but universal.
  2. MessageChannel with postMessage — queues a task immediately, no clamping. This is what React 18 uses internally for time-slicing.
  3. scheduler.yield() (Chrome 115+) — promises a yield point that allows input and rendering to run, then resumes the same logical task with priority. Modern recommendation: use scheduler.yield(), fall back to setTimeout(0). Do not use await Promise.resolve() to yield — it queues a microtask, which does not let the renderer run.
Quiz

`setTimeout(fn, 0)` and `queueMicrotask(fn)` both 'schedule something soon.' What is the operational difference?

Quiz

A loop body is `await fetch(url)`. Why does the page still render between iterations even though the loop never returns?

Trace it
1/4

DevTools Performance shows a 320 ms 'long task' on a search input handler. INP for the page reports 410 ms p75. The handler does: parse user input → fetch suggestions → JSON.parse the response → re-render the dropdown. Where is the time?

1
Step 1 of 4
JSON.parse on a large response is the most likely culprit — synchronous on the main thread, scales linearly with bytes
2
Locked
fetch is the bottleneck — network latency
3
Locked
React re-render of dropdown is the bottleneck
4
Locked
Network timeout backoff
Quiz

You want to break a 200 ms task into 50 ms chunks so input can interleave. Which technique actually lets the browser process input between chunks?

Quiz

A worker calls `postMessage(data)` to the main thread. The data is a 5 MB Float32Array. What is the dominant cost?

Order the steps

A click handler runs sync code, then `Promise.resolve().then(...)`, then `setTimeout(..., 0)`, then more sync code. Drag the callbacks into the order they actually fire.

  1. 1 Sync code at top of handler
  2. 2 Sync code at bottom of handler (before return)
  3. 3 Promise.then callback (microtask)
  4. 4 setTimeout callback (task)
Complete the analogy

When two threads (main and worker) exchange data, the messages cross via the task queue, not the microtask queue. So the lower bound for round-trip latency is one task hop each way. What is this hop usually called in performance discussions?

Pick the best fit

You need to schedule code to run 'after this synchronous block, but as soon as possible.' Which primitive?

Compute it

A `setTimeout(fn, 0)` callback after the 5th level of nesting has its delay clamped to a minimum. Per the HTML spec, that minimum (in ms) is:

ms
Recall before you leave
  1. 01
    Explain why `await Promise.resolve()` does not 'yield to the browser' even though it suspends the calling function.
  2. 02
    A worker sends 60 messages per second to the main thread, each carrying a small JSON object. Why does this still cause main-thread jank even though each individual message is tiny?
  3. 03
    Why does a slow event listener on `document` (like a mousemove analytics wrapper) tax every descendant interaction?
Recap

The HTML spec defines multiple task queues — one per task source — and the browser picks one queue per iteration. Microtasks, by contrast, live in a single checkpoint that drains to empty after every task and before every frame. The three scheduling primitives have distinct semantics: queueMicrotask is pre-render, setTimeout(0)/MessageChannel is post-render (task-level yield), and scheduler.yield() is a structured task-level yield that preserves priority. Input events land on the task queue from the OS input thread; a 80 ms running task delays a mid-task click by the full remaining duration. The passive listener option on scroll and touch events lets the compositor thread start immediately instead of waiting for the dispatch task — a concrete win for scroll responsiveness without touching JS logic.

Connected lessons
appears again in267
Continue the climb ↑Timer accuracy, throttling, and idle work
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.