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.
The loop, as the HTML specification defines it
"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.
- 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.
- 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.
- 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).
- 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.
- Idle period. When no tasks are runnable and no frame is due. requestIdleCallback runs here with a deadline.
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.
- Microtasks run before the next task and before rendering. Always. A promise chain that resolves synchronously runs entirely before the browser can paint.
- Microtasks queued during a drain run in that drain. So an unbounded chain starves the loop exactly like a synchronous infinite loop.
- await is a microtask boundary. Code after
await xruns 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. - 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.
- MessageChannel postMessage is a task with no clamp, which is why it was the classic "yield" before scheduler.postTask.
- requestAnimationFrame callbacks run once per rendering opportunity, before style and layout, in the order queued. A rAF queued inside a rAF runs next frame.
- 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.
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 continuationRendering 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.
- Run the resize steps (fire resize if the viewport changed).
- Run the scroll steps (fire scroll events for scrolled elements).
- Evaluate media queries and fire change events.
- Update animations and fire animation events.
- Fire fullscreen change events.
- Run requestAnimationFrame callbacks.
- Run ResizeObserver callbacks (which may loop if they change sizes; the spec limits the loop).
- Run IntersectionObserver callbacks.
- Update the rendering: style, layout, paint, commit.
- Scroll events fire once per frame, not once per scroll tick. Throttling a scroll handler is redundant; batching the work it does is not.
- rAF runs after scroll and resize handlers, before layout. It is the last moment to write styles and have them in this frame.
- 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.
- rAF does not fire in a background tab. Animations pause; use visibilitychange to handle it.
- rAF before the first paint fires with the first frame, so "rAF then rAF" is the idiom for "after the next paint".
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.
- OS to browser process. The platform event (touch, mouse, key) arrives with a timestamp. That timestamp is
event.timeStampand the start of INP's clock. - 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.
- Compositor to main thread. The event becomes a task. If the main thread is busy, it waits. That wait is input delay.
- 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.
- 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.
- 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.
- { passive: true } on wheel, touchstart and touchmove listeners, so the compositor never waits.
- touch-action: manipulation on tappable elements to remove the 300 ms click delay and double-tap zoom.
- pointer-events: none on decorative overlays so they do not become hit targets.
- getCoalescedEvents() for high-frequency pointer data.
- Keep handlers short. Processing time is the second term of INP.
Long tasks, INP and yielding
"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.
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.- 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.
- 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.
- 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 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.
- scheduler.postTask(fn, { priority }) with priorities
user-blocking(above default tasks, below input),user-visible(the default, like setTimeout 0), andbackground(like requestIdleCallback but guaranteed to run eventually). Returns a promise; accepts a signal for cancellation and priority changes via TaskController. - 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".
- navigator.scheduling.isInputPending() reports whether input events are queued, so a loop can yield only when it matters.
- TaskController / TaskSignal let you abort or reprioritise queued work, e.g. lower a prefetch to background when the user starts interacting.
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.Timers, in detail
- 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.
- Nesting clamp. After five nested levels, the minimum delay is 4 ms. A setTimeout(0) loop runs at most 250 times a second.
- 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.
- 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.
- Timer ordering. Two timers with the same delay fire in the order set. A timer with a shorter delay set later can fire first.
- rAF for anything visual.
- scheduler.postTask for prioritised deferral.
- requestIdleCallback for "whenever", with a timeout so it eventually runs.
- performance.now() for measuring elapsed time;
Date.now()can jump with clock changes. - AbortSignal.timeout(ms) for fetch timeouts rather than a racing setTimeout.
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.
- 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.
- 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.
- 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.
- PerformanceObserver. Timing entries: long tasks, layout shifts, largest contentful paint, element timing, event timing. Delivered asynchronously. How RUM libraries collect Core Web Vitals.
- ReportingObserver. Deprecations, interventions, crash reports.
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.
- 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.
- 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.
- 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.
- Timers are throttled less in workers than in a hidden document, but not zero.
Reading the loop in the profile
| What you see | What it means | What to do |
|---|---|---|
| A task with a red hatched corner, over 50 ms | Long task | Chunk with scheduler.yield; move to a worker; defer with postTask background |
| Interactions track bar with a long grey leading segment | Input delay: something was running when the tap arrived | Find the task under the grey segment; that is the one to break up |
| Interactions track bar with a long trailing segment | Presentation delay: handler triggered heavy render | Reduce DOM changed; fix thrash; containment |
| Dense Timer Fired events | setInterval or a timeout loop | rAF for visual; consolidate timers; check for leaked intervals |
| Run Microtasks taking many ms | Long promise chains or a MutationObserver doing heavy work | Yield with a real task boundary; narrow the observer |
| Animation Frame Fired with long children every frame | rAF loop doing non-visual work | Move computation out; keep rAF to style writes |
| Frames track: red frames, main thread busy | Dropped frames from main-thread overrun | All of the above, prioritised by what sits under the red frame |
- Route
/loop/orderingprints 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. - 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. - 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. - Route
/loop/timers-background: start a 100 ms interval, switch tabs for a minute, come back and read the logged intervals.