Moving heavy work into a requestAnimationFrame (rAF) callback doesn’t make that work cheaper or shorter, it only changes when the work runs, and rAF callbacks execute synchronously on the main thread immediately before the next paint. If a team migrates a slow event handler to schedule its work inside requestAnimationFrame without actually breaking that work into smaller pieces, the total main-thread cost is unchanged or can even increase (extra scheduling overhead, an additional frame of delay before the work starts), and because rAF callbacks are positioned right before paint, poorly structured work there can directly inflate the “presentation delay” portion of INP or trigger forced layout recalculation if DOM reads and writes aren’t batched correctly. The fix that actually helps is task chunking with real yields back to the browser, not simply relocating the same monolithic block of work to a different scheduling API.
Mechanism: why rAF doesn’t fix what people assume it fixes
INP (Interaction to Next Paint) is measured across three phases: input delay (time before the browser can start processing the interaction, usually because the main thread is busy with something else), processing time (running the actual event handler logic), and presentation delay (time from the end of processing to the frame actually being presented on screen). A common but mistaken assumption is that “using requestAnimationFrame” is inherently a performance optimization because it sounds related to smooth animation. In reality, rAF is a scheduling primitive: it tells the browser “run this function right before you’re about to paint the next frame,” and the callback still executes synchronously on the main thread when that time comes. If the callback contains 100ms of computation, that’s still a 100ms blocking task, now positioned immediately before paint rather than immediately on interaction, which can make it directly count against presentation delay instead of processing time. Total blocking time isn’t reduced; its position in the timeline is what moved.
There are two specific failure patterns that show up repeatedly when teams migrate synchronous handlers to rAF without chunking:
Deferred-but-still-monolithic work. A handler that used to run 150ms of work synchronously on click now wraps that same 150ms of work in a single requestAnimationFrame(() => { /* same 150ms */ }) call. The work hasn’t been split at all, it’s simply been pushed to run right before the next paint instead of immediately. Since presentation delay is explicitly the gap between “processing finished” and “frame painted,” and rAF callbacks are part of what has to complete before that paint can happen, this pattern directly extends presentation delay. The interaction can end up feeling less responsive than before, because now there’s an extra frame of latency added on top of the same blocking cost, rather than the blocking cost being reduced.
Layout thrashing from unbatched DOM reads and writes inside the callback. Because rAF fires right before the browser’s style/layout/paint pipeline runs, it’s a common place for code to read layout properties (offsetHeight, getBoundingClientRect, etc.) and write DOM changes in the same callback. If reads and writes are interleaved rather than batched (read all needed values first, then perform all writes), the browser is forced to synchronously recalculate layout multiple times within that single callback to satisfy each read, since a pending write invalidates cached layout information. This is the classic “layout thrashing” pattern, and rAF doesn’t prevent it, it can make it more likely to occur precisely because rAF callbacks are exactly the place developers put “do a DOM update before the next frame” logic, which is naturally read/write-heavy.
Neither failure mode means rAF itself is a poor choice for scheduling paint-aligned work like visual animations, where it remains the correct API. The problem is specifically using it as a substitute for real task-splitting when migrating an already-heavy synchronous handler, under the mistaken belief that changing the scheduling API changes the cost of the work being scheduled.
The correct pattern: yield-based chunking, not relocation
Web.dev’s guidance on optimizing INP and on breaking up long tasks is built around the idea that any single task blocking the main thread for a long stretch (Chrome’s Long Tasks API flags tasks over 50ms) delays input handling and rendering, regardless of which scheduling API queued that task. The fix is to yield control back to the browser periodically during heavy work, so the browser gets a chance to handle pending input and paint frames in between chunks, rather than running the entire block uninterrupted.
Practical chunking mechanisms, in rough order of how directly they address the problem:
scheduler.yield() (part of the Scheduler API), where supported, is purpose-built for this: it lets a function pause and yield to the main thread’s event loop, then resume as a continuation, with priority hints available for how urgently the remaining work should run relative to other pending tasks. This is the most direct fit for chunking long JavaScript work in an INP-sensitive context.
setTimeout(fn, 0) as a broadly-supported yielding mechanism: scheduling the next chunk of work via setTimeout forces it onto a new task rather than continuing synchronously, which gives the browser an opportunity to process input and paint between chunks. It’s less precise than the Scheduler API (subject to browser timer clamping/throttling behavior and lacking priority hints) but works everywhere.
isInputPending(), where supported, lets a long-running chunked task check whether user input is waiting to be handled and yield early if so, rather than running a full fixed-size chunk regardless of whether the user just interacted. This is useful for work that isn’t easily divided into small fixed units but can check for a good stopping point periodically.
Batching DOM reads and writes explicitly regardless of which scheduling primitive is used: gather all needed layout measurements first, then perform all DOM mutations afterward, so the browser only needs to recalculate layout once rather than once per interleaved read/write pair. This applies whether the code runs in a rAF callback, an event handler, or a chunked task.
The general principle that generalizes past this specific rAF case: any scheduling API (rAF, setTimeout, microtasks, the Scheduler API) only controls when a piece of work runs relative to the browser’s rendering pipeline, not how expensive that work is. Improving INP requires reducing the actual duration of blocking work per task, through chunking and yielding, or reducing the total amount of work done, through optimization or lazy execution. Simply moving the same unbroken block of work to a different scheduling point can shift which INP sub-metric absorbs the cost, and in the case of rAF specifically, that shift is often toward presentation delay, which is why the migration can look like a regression rather than an improvement.