Part 6 · 10 chapters · ~70 min

The Event Loop, As The Browser Runs It

JavaScript is single-threaded; the event loop is what decides what runs next, and it belongs to the browser rather than the engine. Tasks run to completion, microtasks drain between them, frames are rendered when the loop decides a frame is due, and input has to find a gap. This part is the loop as the HTML specification defines it, the ordering puzzles it produces, requestAnimationFrame and the rendering steps, the input pipeline, long tasks and INP, the scheduling API, timers, observers, and how all of it reads in a profile.

65

The loop, as the HTML specification defines it

the question

"JavaScript is single-threaded. So what decides what runs next?"

The event loop, which is defined by the HTML specification rather than by JavaScript, and which belongs to the browser rather than the engine. V8 executes; the loop schedules. The schedule is: pick a task, run it to completion, drain microtasks, maybe render, repeat.

the vocabulary, precisely
  1. Task (macrotask). A unit of work from a task source: a script block, a timer callback, an input event dispatch, a network callback, a postMessage. Runs to completion. Only one at a time.
  2. Task source and task queue. Tasks are grouped by source. The loop may choose which queue to service next, and browsers prioritise input and rendering-related sources. This is why your timer does not fire exactly when you asked, and why an input event can jump ahead of a queued fetch callback.
  3. Microtask. Promise reactions, queueMicrotask, MutationObserver notifications. The microtask queue is drained to empty after every task and after every callback the browser invokes from its own code (which is why a microtask queued inside an event listener runs between listeners, not after all of them).
  4. Rendering opportunity. A point in the loop where, if the display is ready for a frame, the browser runs animation frame callbacks, resize and scroll events, intersection observations, style, layout, paint and commit. Roughly once per vsync.
  5. Idle period. When no tasks are runnable and no frame is due. requestIdleCallback runs here with a deadline.
one loop per agent
Each window shares a loop with same-origin windows it can synchronously reach (iframes, opener). Each worker has its own loop. Cross-origin iframes may get their own loop or process. A busy same-origin iframe freezes the parent; a cross-origin one does not.
THE EVENT LOOP
tasks, microtasks, and rendering opportunities
swipe the figure sideways, or tap expand for full screen
1/7
task queues
Several task queues, not one: timers, network callbacks, user input, postMessage, each a source. The loop picks one task per iteration, with priority to input and rendering-related sources when they are pending.
66

Tasks, microtasks and the ordering that follows

The loop's rules produce an ordering that is deterministic for microtasks and only partly deterministic for tasks relative to frames. Knowing it lets you place work exactly.

the facts to hold
  1. Microtasks run before the next task and before rendering. Always. A promise chain that resolves synchronously runs entirely before the browser can paint.
  2. Microtasks queued during a drain run in that drain. So an unbounded chain starves the loop exactly like a synchronous infinite loop.
  3. await is a microtask boundary. Code after await x runs as a microtask, even if x is already resolved. Two awaits in a row are two microtask hops. This is cheap, but it is not free, and it is not a yield to the browser.
  4. setTimeout(fn, 0) is at least 4 ms after four nested levels, and is clamped to once per second in background tabs. It is a task: rendering may happen before it.
  5. MessageChannel postMessage is a task with no clamp, which is why it was the classic "yield" before scheduler.postTask.
  6. requestAnimationFrame callbacks run once per rendering opportunity, before style and layout, in the order queued. A rAF queued inside a rAF runs next frame.
  7. MutationObserver delivers as a microtask after the DOM mutation completes, batched, which is why it is usable for "after this change" without a frame delay.
worked numbers
the common mistake and the fix:

  // "let the UI update, then do heavy work"
  await Promise.resolve();          // microtask: UI does NOT update
  heavy();

  await new Promise(r => requestAnimationFrame(() => setTimeout(r)));
                              // after the next paint
  heavy();

  await scheduler.yield();          // a task boundary, prioritised continuation
ORDERING PUZZLE
sync, microtask, rAF, task
swipe the figure sideways, or tap expand for full screen
1/6
sync
The script is itself a task. It runs top to bottom: console.log("A") fires immediately. The other four are scheduled, not run.
67

Rendering opportunities and requestAnimationFrame

The browser does not render after every task. It renders when it decides a frame is worth producing, which is tied to the display's refresh and to whether anything changed. Understanding that timing is what makes rAF the right tool and setTimeout the wrong one for visual work.

what happens at a rendering opportunity, in order
  1. Run the resize steps (fire resize if the viewport changed).
  2. Run the scroll steps (fire scroll events for scrolled elements).
  3. Evaluate media queries and fire change events.
  4. Update animations and fire animation events.
  5. Fire fullscreen change events.
  6. Run requestAnimationFrame callbacks.
  7. Run ResizeObserver callbacks (which may loop if they change sizes; the spec limits the loop).
  8. Run IntersectionObserver callbacks.
  9. Update the rendering: style, layout, paint, commit.
what this tells you
  1. Scroll events fire once per frame, not once per scroll tick. Throttling a scroll handler is redundant; batching the work it does is not.
  2. rAF runs after scroll and resize handlers, before layout. It is the last moment to write styles and have them in this frame.
  3. ResizeObserver runs after rAF and after layout, delivering real sizes. Writing styles in it triggers another layout before paint, which the spec allows a bounded number of times.
  4. rAF does not fire in a background tab. Animations pause; use visibilitychange to handle it.
  5. rAF before the first paint fires with the first frame, so "rAF then rAF" is the idiom for "after the next paint".
the measurement that depends on this
Interaction to Next Paint measures from input to the frame that reflects the handler's changes. A handler that writes styles synchronously and a handler that writes them in rAF present in the same frame. A handler that writes them in setTimeout may present a frame later. The loop decides which.
68

Input: from hardware to handler

Input is a task source with priority, but it has a longer path than most people picture, and the compositor is in the middle of it.

the path
  1. OS to browser process. The platform event (touch, mouse, key) arrives with a timestamp. That timestamp is event.timeStamp and the start of INP's clock.
  2. Browser process to the renderer's compositor thread. The compositor has hit-test regions from the last commit. It decides whether it can handle the event alone (a scroll on a region with only passive listeners) or must forward it to the main thread and wait.
  3. Compositor to main thread. The event becomes a task. If the main thread is busy, it waits. That wait is input delay.
  4. Hit testing on the main thread. A precise walk of the layout tree in reverse paint order to find the topmost box at the point, respecting pointer-events, clip paths and transforms. The result is the event target.
  5. Dispatch. Capture listeners from window down, target listeners, bubble listeners back up. Default actions (link navigation, form submission, text input) run after, unless preventDefault was called.
  6. Derived events. pointerdown and pointerup produce click. touchstart and touchend produce synthetic mouse events for compatibility, delayed by the famous 300 ms unless the viewport is configured or touch-action is set.
the controls you have
  1. { passive: true } on wheel, touchstart and touchmove listeners, so the compositor never waits.
  2. touch-action: manipulation on tappable elements to remove the 300 ms click delay and double-tap zoom.
  3. pointer-events: none on decorative overlays so they do not become hit targets.
  4. getCoalescedEvents() for high-frequency pointer data.
  5. Keep handlers short. Processing time is the second term of INP.
FROM TOUCH TO HANDLER
the input pipeline and hit testing
swipe the figure sideways, or tap expand for full screen
1/6
OS to browser
The OS delivers a touch or pointer event to the browser process, which knows which tab and which renderer should receive it.
69

Long tasks, INP and yielding

the question

"Our click handler is 15 ms. Why is INP 400 ms?"

Because INP measures from the tap to the next paint, and the handler is only the middle of that. If a 300 ms task was running when the tap landed, the handler waited 300 ms to start. If the handler's changes triggered a 60 ms layout, the paint waited for that. INP is the whole chain; the handler is one link.

worked numbers
INP, decomposed:

  input delay         time from the event's timestamp until its handler starts
                         = whatever task was already running, plus queued tasks ahead
  processing          all handlers for the interaction (pointerdown, pointerup, click)
  presentation delay  from the last handler's end to the frame that shows the result
                         = style + layout + paint of what the handlers changed

  good < 200 ms   needs improvement < 500 ms   poor beyond

the reported INP is near the worst interaction of the visit (98th percentile), not the average.
the fixes, by term
  1. Input delay: no task over 50 ms on the main thread, ever. Chunk with scheduler.yield(); move computation to workers; defer non-critical work with requestIdleCallback or postTask at background priority.
  2. Processing: do the minimum synchronously, show the visual response, then do the rest after a yield. A button that changes its own state immediately and queues the data work feels instant even if the data takes 200 ms.
  3. Presentation delay: change less DOM per interaction; avoid layout thrash; use content-visibility on large hidden regions; keep the changed subtree small so style and layout stay small.
the lens
The Performance panel's Interactions track draws each interaction as a bar split into the three terms. Click the bar: it tells you which term is large. Then the main thread track directly below it shows the task responsible.
BREAKING A LONG TASK
yield, so input and frames get in
swipe the figure sideways, or tap expand for full screen
1/6
one 300 ms task
One task: a loop over 3,000 items taking 300 ms. During it, a tap arrives and a frame is due. Both wait. The user sees a frozen page for 300 ms and the tap handler runs at the end.
70

The Prioritised Task Scheduling API

setTimeout, MessageChannel and requestIdleCallback were the tools for a decade. scheduler.postTask and scheduler.yield are the designed replacement: tasks with priorities the browser understands.

the API
  1. scheduler.postTask(fn, { priority }) with priorities user-blocking (above default tasks, below input), user-visible (the default, like setTimeout 0), and background (like requestIdleCallback but guaranteed to run eventually). Returns a promise; accepts a signal for cancellation and priority changes via TaskController.
  2. scheduler.yield() returns a promise that resolves as a new task whose priority is inherited from the current one, and which is placed ahead of other tasks of the same priority that arrived meanwhile. This is the fix for "setTimeout 0 yields, but my continuation then waits behind forty analytics timers".
  3. navigator.scheduling.isInputPending() reports whether input events are queued, so a loop can yield only when it matters.
  4. TaskController / TaskSignal let you abort or reprioritise queued work, e.g. lower a prefetch to background when the user starts interacting.
worked numbers
a pattern for a heavy interaction:

  button.onclick = async () => {
    button.disabled = true;             // visible now, this frame
    await scheduler.yield();            // let that paint
    for (const chunk of chunks(items, 200)) {
      render(chunk);
      await scheduler.yield();
    }
    scheduler.postTask(sendAnalytics, { priority: 'background' });
  };

the user sees the button respond in one frame; the work drains without blocking input; the analytics never compete.
71

Timers, in detail

what setTimeout and setInterval actually promise
  1. A minimum delay, never an exact one. The task is queued when the timer fires and runs when the loop gets to it. Under load, late by however long the current task is.
  2. Nesting clamp. After five nested levels, the minimum delay is 4 ms. A setTimeout(0) loop runs at most 250 times a second.
  3. Background throttling. Hidden tabs clamp timers to once per second, and after a few minutes Chrome budgets them to a fraction of a second of CPU per minute. Anything that must keep accurate time in the background needs a worker (also throttled, less) or a server.
  4. setInterval drifts. If the callback takes longer than the interval, invocations pile up or are skipped. For animation, rAF; for scheduling, setTimeout that re-arms itself from the actual elapsed time.
  5. Timer ordering. Two timers with the same delay fire in the order set. A timer with a shorter delay set later can fire first.
what to use instead
  1. rAF for anything visual.
  2. scheduler.postTask for prioritised deferral.
  3. requestIdleCallback for "whenever", with a timeout so it eventually runs.
  4. performance.now() for measuring elapsed time; Date.now() can jump with clock changes.
  5. AbortSignal.timeout(ms) for fetch timeouts rather than a racing setTimeout.
72

Observers: the loop-friendly way to react

Three observer APIs deliver information the browser already computes, batched at well-defined points in the loop, so you never have to poll or force layout.

which one, when
  1. MutationObserver. DOM changes. Delivered as a microtask after the mutating task, with the mutations batched. Use it to react to DOM changes you do not control (third-party widgets, contenteditable). Do not use it as a general event bus; it fires for everything in its scope.
  2. ResizeObserver. Element size changes. Delivered in the rendering steps, after layout, before paint, with real sizes. The right way to implement element queries in JS and to size canvases. Writing styles in the callback can trigger a bounded relayout loop; the "ResizeObserver loop completed with undelivered notifications" error is that limit being hit.
  3. IntersectionObserver. Visibility relative to a root. Delivered in the rendering steps with precomputed intersection rectangles. Lazy loading, infinite scroll triggers, analytics for impressions, pausing off-screen work. It replaces every scroll handler that called getBoundingClientRect.
  4. PerformanceObserver. Timing entries: long tasks, layout shifts, largest contentful paint, element timing, event timing. Delivered asynchronously. How RUM libraries collect Core Web Vitals.
  5. ReportingObserver. Deprecations, interventions, crash reports.
the shared property
All of them are callbacks the browser invokes at a point in the loop where the data is already consistent. They are the opposite of polling: zero cost when nothing changes, one batched callback when something does. A scroll handler that reads positions sixty times a second is the pattern they exist to retire.
73

Workers and the loop, briefly

Each worker has its own event loop on its own thread, which is why moving work there frees the main thread. Part 7 covers workers fully; here is the loop-level view.

what changes in a worker
  1. No rendering steps. The worker loop is tasks and microtasks only. No rAF (except in workers that own an OffscreenCanvas, which get their own rAF), no layout, no DOM.
  2. postMessage is a task on the receiving side. A message from a worker arrives as a task in the main loop; it waits behind whatever is running. A worker cannot interrupt the main thread, only queue for it.
  3. Structured clone copies. Large messages cost time on both ends. Transferables (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas) move instead of copy. SharedArrayBuffer shares, with Atomics for coordination, when cross-origin isolation is enabled.
  4. Timers are throttled less in workers than in a hidden document, but not zero.
the rule for choosing
If the work touches the DOM, chunk it on the main thread. If it does not, put it in a worker. Parsing a large JSON response, diffing data, computing layout for a virtualised list, image processing, search indexing: all workers. The cost is the message copy; measure it against the task length you are removing.
74

Reading the loop in the profile

What you seeWhat it meansWhat to do
A task with a red hatched corner, over 50 msLong taskChunk with scheduler.yield; move to a worker; defer with postTask background
Interactions track bar with a long grey leading segmentInput delay: something was running when the tap arrivedFind the task under the grey segment; that is the one to break up
Interactions track bar with a long trailing segmentPresentation delay: handler triggered heavy renderReduce DOM changed; fix thrash; containment
Dense Timer Fired eventssetInterval or a timeout looprAF for visual; consolidate timers; check for leaked intervals
Run Microtasks taking many msLong promise chains or a MutationObserver doing heavy workYield with a real task boundary; narrow the observer
Animation Frame Fired with long children every framerAF loop doing non-visual workMove computation out; keep rAF to style writes
Frames track: red frames, main thread busyDropped frames from main-thread overrunAll of the above, prioritised by what sits under the red frame
go to the lab
  1. Route /loop/ordering prints A to E with the loop annotated. Change the code in the page's editor to add a nested microtask and predict the order before running.
  2. Route /loop/long-task: click the button, feel the freeze, read the Interactions bar. Switch to the chunked version and compare INP in the summary.
  3. Route /loop/passive: scroll with a non-passive wheel listener that does 30 ms of work, then with passive. Watch the scroll smoothness and the input delay.
  4. Route /loop/timers-background: start a 100 ms interval, switch tabs for a minute, come back and read the logged intervals.