awesome-everything RU
↑ Back to the climb

Browser & Frontend Runtime

Microtask starvation, Long Tasks, and LoAF

Crux The starvation failure mode, how to detect it in production with PerformanceLongTaskTiming and LoAF, and scheduler.yield() / scheduler.postTask() as the cure.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 16 min

A user reports the page “froze completely” for several seconds. DevTools Performance shows one uninterrupted yellow scripting bar. No function call dominates the flame chart — the work keeps re-scheduling itself as microtasks and the loop never escapes.

Failure mode: microtask starvation

Microtasks drain to empty between tasks. If a microtask schedules another microtask before returning, the loop never escapes the microtask checkpoint — no rendering, no input, no further tasks. The pathological example:

function loop() {
  Promise.resolve().then(loop);  // schedules itself as a microtask
}
loop();

This freezes the page indefinitely: the microtask queue is never empty, step 4 (rendering) never runs, the user sees a frozen tab. DevTools Performance shows it as a single uninterrupted yellow scripting bar. The same pattern appears in production as accidental recursion through async chains: a .then that resubscribes to the same observable, a router middleware that re-resolves on every change, a state library that re-fires reactions inside a reaction.

Detection: long-task entries with no clear single function, scripting bar that grows without bound, INP > 1 s.

Cure: insert at least one task-level yield (setTimeout, MessageChannel, scheduler.postTask) into the chain.

Microtask starvation signals in DevTools
Yellow scripting bar
continuous, never breaks
Long Tasks API entries
duration grows without bound
INP
> 1 000 ms
Frame counter
0 fps during starvation
Fix
insert a task-level yield in the chain
Quiz

A `MutationObserver` callback fires synchronously after a DOM mutation. What kind of work is it queued as?

Long Tasks API and Long Animation Frames

PerformanceLongTaskTiming. Shipped in 2017, fires a PerformanceObserver entry for any task that exceeds 50 ms on the main thread. Each entry includes start time, duration, and an attribution array with the originating browsing context. Limitation: the attribution rarely points to a specific function; it tells you “something inside iframe A took 230 ms” — useful for SLO dashboards, less useful for root-cause. Use the Long Tasks API to count long tasks per session, not to debug them.

PerformanceLongAnimationFrameTiming (LoAF). Shipped 2023–2024 in Chromium. Fires for any frame whose render time exceeds 50 ms. A LoAF entry includes per-script attribution: an array of which scripts ran in the frame, what they did (event-handler, classic-script, module-script, user-callback), how long each took. This is the production diagnostic that long tasks should have been from the start. Combine with INP: when INP regresses, query LoAF entries from the same session, find the offending frame, get the script attribution, deploy a fix. The full pipeline: from user-perceived metric (INP) to specific script (LoAF) to specific function (sourcemap on the script URL).

Debug this
log
[Long Task] 312 ms
attribution: same-origin
startTime: 2456.3
duration: 312.4

[Long Task] 287 ms
attribution: same-origin
startTime: 5102.7
duration: 287.1

[INP candidate] 410 ms
type: pointerdown -> click -> next paint
startTime: 2401.0
processingStart: 2456.3
processingEnd: 2768.7
presentationTime: 2811.0
attribution: handleSearch (search.js:142)

A search input shows 410 ms INP and the long-task log points to handleSearch. The handler does: validate query → call setSearchTerm (Redux) → trigger debounced fetch → wait for results. Where is the 312 ms?

scheduler.yield() and the Scheduler API

What scheduler.yield() does. The Scheduler API (Chrome 115+, partial in Edge, Firefox/Safari behind flag as of late 2025) gives the platform a first-class yield primitive. Awaiting scheduler.yield() suspends the current task, drains the microtask queue, allows input and rendering to run, and resumes the suspended task as the next task — with priority that prevents low-priority work from cutting in front. Previously, yielding via setTimeout(0) worked for rendering but lost queue position (any task scheduled in the meantime would run first). With scheduler.yield(), you can split a 200 ms task into four 50 ms chunks that are still effectively one logical operation from the user’s perspective.

scheduler.postTask() with priorities. The same API exposes scheduler.postTask(callback, { priority }) with three priorities: user-blocking (input-response work), user-visible (default), background (non-urgent). Practical pattern: route input handlers through user-blocking so they jump the queue ahead of background work like analytics flushes; route background analytics through background so they yield to anything urgent. The scheduler has access to system signals (battery, thermal throttling, page visibility) that JS code does not.

Yield a long task without losing logical continuity

1/3
Which RFC?

Which specification defines the event loop, task queues, microtask queue, and the rendering steps in step-by-step detail?

Design challenge

Design the input pipeline for a search bar that filters 50 000 client-side items and must keep INP under 200 ms p75 on mid-range Android.

  • Frame budget: ~10 ms after browser overhead.
  • INP target: ≤200 ms p75.
  • No long tasks > 50 ms on the main thread during typing.
  • Filter results must update visibly within 200 ms of the most recent keystroke.
  • Off-screen results may render lazily but on-screen results must be correct.
  • Browser support: Chrome, Safari, Firefox (no Worker fallback drama).
Quiz

LoAF (PerformanceLongAnimationFrameTiming) differs from PerformanceLongTaskTiming in what key way?

Recall before you leave
  1. 01
    A search input shows 410 ms INP. LoAF reports a 312 ms script attributed to handleSearch. Walk through how to root-cause this from telemetry to fix.
  2. 02
    What is the difference between scheduler.yield() and await Promise.resolve() as yield mechanisms?
  3. 03
    Describe the microtask starvation pattern and give one production scenario where it appears accidentally.
Recap

Microtask starvation occurs when a microtask enqueues another microtask before returning — the loop is trapped in the microtask checkpoint and never reaches the render or input steps. It shows in DevTools as a single unbroken yellow bar and in production as INP > 1 s. The Long Tasks API (PerformanceLongTaskTiming, 2017) counts tasks exceeding 50 ms but gives only browsing-context attribution. LoAF (PerformanceLongAnimationFrameTiming, 2023) fires per frame and gives per-script attribution, making it the correct tool for root-causing INP regressions in production. The Scheduler API’s scheduler.yield() (Chrome 115+) provides a structured task-level yield that preserves queue priority, while scheduler.postTask() lets you assign user-blocking, user-visible, or background priority to work so the browser’s own scheduler — which has access to thermal and battery signals — can make better decisions than any hand-rolled priority queue.

Connected lessons
appears again in143
Continue the climb ↑Node.js event loop: phases, nextTick, and loop lag
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.