Why can replacing synchronous JavaScript with requestAnimationFrame callbacks sometimes increase INP rather than decrease it?

The question is not whether requestAnimationFrame is a useful scheduling tool. It is. The question is why using it as an INP optimization technique — deferring work from a synchronous event handler into a rAF callback — can increase the measured interaction duration instead of decreasing it. The mechanism is counterintuitive: rAF callbacks execute at the beginning of the next frame’s rendering pipeline, which means the browser counts the rAF work as part of the same interaction’s presentation delay if the browser has not yet painted a frame reflecting the interaction’s result. Moving work from processing time to presentation delay does not reduce INP — it shifts the same duration into a different measurement phase.

How requestAnimationFrame Scheduling Interacts with INP Phases

INP measures three sequential phases for each interaction: input delay (time from user input to handler start), processing time (event handler execution duration), and presentation delay (time from handler completion to the next frame paint that reflects the interaction’s visual result). The sum of all three phases constitutes the interaction’s total duration, and INP reports the near-worst interaction across the session.

When an event handler defers work into a requestAnimationFrame callback, the processing time phase decreases because the synchronous handler itself completes faster. The handler finishes, the browser notes the handler completion timestamp, and the presentation delay phase begins. But the rAF callback runs before the next paint, as part of the browser’s rendering pipeline for that frame. Its execution time falls entirely within the presentation delay phase.

Since INP equals input delay + processing time + presentation delay, and the rAF work simply moved from the processing phase to the presentation phase, the total interaction duration does not change. The metric reports the same number. The work was rearranged, not eliminated. Google’s INP codelab demonstrates this directly: moving a blocking call into a requestAnimationFrame callback produces the same interaction duration, with all the time now attributed to presentation delay instead of processing time.

This matters because developers who see “high processing time” in their INP attribution data and respond by deferring handler work to rAF will observe the processing time decrease and the presentation delay increase by the same amount. The INP score remains unchanged. The optimization achieved nothing except reclassifying where the time is spent.

The Frame Timing Problem: rAF Runs Before Paint, Not After

The fundamental issue is the position of requestAnimationFrame callbacks in the browser’s frame lifecycle. The rendering pipeline for each frame follows this sequence:

  1. Input events are processed (event handlers execute)
  2. requestAnimationFrame callbacks execute
  3. Style recalculation, layout, and paint occur
  4. The frame is composited and displayed

rAF callbacks execute at step 2, before the paint at step 3. This is by design — rAF is intended for visual updates that must synchronize with the display refresh rate. Animation code, canvas drawing, and DOM mutations that need to be painted in the current frame belong in rAF callbacks.

But for INP purposes, any code that executes before the paint that reflects the interaction’s visual result extends the interaction’s measured duration. The browser cannot paint the interaction’s result until the rAF callback completes, because the rAF callback might modify the DOM in ways that affect the painted output. The browser must execute the rAF callback, then run style/layout/paint. The entire sequence — from input event through rAF execution through paint — constitutes the interaction duration.

The Long Animation Frames (LoAF) API confirms this timing. LoAF entries report scripts executed during each frame, including rAF callbacks. Scripts that run within a rAF between an interaction event and the subsequent paint appear as contributors to the interaction’s presentation delay. The renderStart timestamp in LoAF marks when rAF callbacks begin, and styleAndLayoutStart marks when the post-rAF rendering work begins. Any rAF execution time between these markers directly extends the interaction duration.

When rAF Deferral Actually Worsens INP

The scenario where rAF deferral makes INP actively worse (not just unchanged) occurs when the synchronous handler was fast enough to complete within the current frame’s budget, but the rAF callback pushes the visual update to the next frame boundary.

Consider a synchronous handler that takes 50ms. At 60fps (16.67ms per frame), this handler spans approximately 3 frames but the browser can paint the result immediately after the handler completes — at approximately the 50ms mark. The interaction duration is roughly 50ms plus a few milliseconds of paint time.

If the developer moves 30ms of that work into a rAF callback, the handler now takes 20ms, completing within one frame. But the rAF callback is scheduled for the next frame’s rendering pipeline, which begins at the next 16.67ms boundary. The rAF callback executes for 30ms starting at the next frame boundary, and paint occurs after that. The total interaction duration is now longer: the handler’s 20ms, plus the wait until the next frame boundary (up to 16ms), plus the rAF callback’s 30ms, plus paint time. The frame boundary wait is pure overhead that the synchronous version did not incur.

This frame-alignment penalty means that rAF deferral can add up to one full frame period (16.67ms at 60fps) of additional latency compared to synchronous execution, in addition to providing zero reduction in total work time. For interactions near the 200ms INP threshold, this added latency can push a passing interaction into failure.

Correct Deferral Patterns and Diagnosing rAF as an INP Contributor

The correct technique for INP optimization defers non-visual computation to a new task that runs after the current frame paints, rather than within the current frame’s rendering pipeline. Three approaches achieve this:

scheduler.yield() immediately yields to the browser, allowing pending paint and input processing, then resumes the remaining work in a new task at the front of the queue:

async function handleClick() {
  updateVisualFeedback(); // Minimal DOM update
  await scheduler.yield();
  performExpensiveComputation(); // Runs after paint
}

The rAF + setTimeout pattern provides a polyfill for requestPostAnimationFrame (which does not exist as a native API). The rAF schedules a callback at the next frame’s render point, and the setTimeout(fn, 0) inside it queues the work after that frame paints:

function afterNextPaint(callback) {
  requestAnimationFrame(() => {
    setTimeout(callback, 0);
  });
}

function handleClick() {
  updateVisualFeedback();
  afterNextPaint(() => {
    performExpensiveComputation();
  });
}

This pattern ensures the browser paints the visual feedback (the interaction’s result) before executing the deferred work. The paint occurs, INP measurement closes at that paint timestamp, and the expensive computation runs in a subsequent task that is not attributed to the original interaction.

scheduler.postTask() with explicit priority provides fine-grained control over when deferred work executes relative to other scheduled tasks. Using priority: 'background' for truly non-urgent computation ensures it does not compete with interaction handling or paint.

The key distinction between these patterns and bare requestAnimationFrame is the paint boundary. rAF runs before paint (extending the interaction). rAF + setTimeout, scheduler.yield(), and scheduler.postTask() all schedule work after paint (excluded from the interaction).

Chrome DevTools’ Performance panel provides the primary diagnostic tool. In the flame chart, rAF callbacks appear as “Animation Frame Fired” entries. If an “Animation Frame Fired” entry executes between an interaction event (visible in the “Interactions” lane) and the subsequent paint (visible as a green bar in the “Frames” lane), the rAF callback is contributing to that interaction’s presentation delay.

The Long Animation Frames API provides programmatic access to this attribution. LoAF entries report all scripts executed during each frame, including their source URL, function name, and execution duration. For scripts running during the render phase (between renderStart and styleAndLayoutStart), LoAF identifies them as rAF callback contributors. Logging LoAF data alongside INP attribution in RUM reveals how frequently rAF callbacks contribute to interaction duration across the user population.

A practical diagnostic test: create a conditional code path that bypasses the rAF deferral and executes the work synchronously within the event handler. Compare the INP attribution data between the two paths. If the synchronous version shows higher processing time but identical total duration, the rAF deferral is providing zero benefit. If the synchronous version shows lower total duration (because it avoids the frame-alignment penalty), the rAF deferral is actively harmful and should be replaced with the rAF + setTimeout pattern or scheduler.yield().

Is scheduler.yield() always preferable to requestAnimationFrame for deferring work in event handlers?

For INP optimization, yes. scheduler.yield() returns control to the browser immediately, allowing the current frame to paint before the deferred work resumes in a new task. requestAnimationFrame schedules work before the next paint, which means the deferred code still executes within the same interaction’s presentation phase. scheduler.yield() cleanly separates the visual update from the deferred computation, producing lower INP.

Does wrapping event handler work in setTimeout(fn, 0) produce the same INP benefit as scheduler.yield()?

setTimeout(fn, 0) defers work to a new macrotask, which does allow the current frame to paint first. However, the browser may schedule other queued tasks before the setTimeout callback, introducing unpredictable delays. scheduler.yield() provides priority-aware scheduling that resumes the deferred work sooner than a zero-delay setTimeout in most cases, producing more consistent INP improvements.

Can requestAnimationFrame callbacks from different event handlers stack and compound INP for the next interaction?

Yes. If multiple interactions fire in rapid succession and each defers work to rAF, the rAF callbacks from earlier interactions may still be pending when a new interaction begins. The browser executes all queued rAF callbacks in the same frame, and if a new interaction’s input event fires during that frame, the accumulated rAF work inflates the input delay phase of the new interaction’s INP measurement.

Sources

Leave a Reply

Your email address will not be published. Required fields are marked *