Long Animation Frames API: Debug INP in Production
A production playbook for finding the script that broke your interaction

For years the answer to "what is causing my INP" was often met with a blank stare. The Long Tasks API told you the main thread was blocked for more than 50ms. It did not tell you which script. It did not tell you which line. All we had was a number in ms.
The Long Animation Frames API fixes that. It ships in Chromium browsers since Chrome 123 and gives you the full anatomy of a frame: total duration, blocking duration, the moment the renderer started, the moment style and layout started, and an array of every script that ran in the frame longer than 5ms. Each script entry includes the source URL, the function name, the character position in the file, and the type of callback that invoked it.
This is the data INP debugging always needed. The rest of this article is about how to use it on a real production site without making things worse.
This page is part of our Interaction to Next Paint (INP) series. The LoAF playbook below is what I run after diagnosing the metric. If you are new to INP, start with the three phase guides for input delay, processing time, and presentation delay, and the broader find-and-fix INP workflow.
Why Long Tasks was not enough
A long task fires when a single task on the main thread runs longer than 50ms. That is useful for catching the obvious blockers. It is useless for INP because most slow interactions are not one long task. They are a sequence of medium tasks plus a long render.
An interaction can fail INP without ever triggering a long task. A 40ms event handler followed by a 70ms style recalc adds up to 110ms of presentation delay. The Long Tasks API reports nothing. The user feels the lag.
The other failure of Long Tasks is attribution. You get a container type, a container name, and a name field telling you whether the long task was "self", "same-origin-ancestor", "same-origin-descendant", "unknown", or one of a few cross-origin variants. You do not get the script that ran. So even when you catch a long task, you still have to open DevTools and re-record the interaction to find the cause. That works in a lab. It does not work for INP on real users you cannot reproduce.
LoAF replaces both of these limitations. It captures the entire frame, not a single task. And it tells you exactly which script ran, by URL and function name.
The seven fields that matter
A LoAF entry has more fields than you need. These seven are the ones I read first when debugging an interaction.
duration is the total length of the frame in milliseconds. A LoAF entry is only created when this exceeds 50ms. This number alone tells you whether the frame is a problem, but not why.
blockingDuration is more useful. It sums the parts of the frame that exceeded the long task threshold, subtracting 50ms from each. A frame with duration: 320 and blockingDuration: 0 is mostly rendering work. A frame with duration: 320 and blockingDuration: 270 is a long script. The first needs a render fix. The second needs a code change.
renderStart is the timestamp where the browser started doing rendering work for the frame. Everything between startTime and renderStart is script execution. Everything after is style, layout, and paint. This is the single most useful boundary for INP debugging because it tells you whether the bottleneck is JavaScript or rendering.
styleAndLayoutStart is the timestamp where rendering moved from running animation frame callbacks into actual style recalculation and layout. The gap between renderStart and styleAndLayoutStart is your requestAnimationFrame callbacks. The gap between styleAndLayoutStart and the end of the frame is browser-side style and layout work.
firstUIEventTimestamp is when a user input arrived during the frame. If this value is non-zero, the frame contains an interaction. This is how web-vitals.js correlates LoAF entries with INP measurements. No firstUIEventTimestamp means the frame is slow but no user was waiting on it.
scripts is the array you read for attribution. Each entry is a script that ran for at least 5ms. It includes sourceURL, sourceFunctionName, invoker, invokerType, duration, and forcedStyleAndLayoutDuration. The last one is critical: it tells you whether the script triggered synchronous layout, which is the root cause of most processing-time bottlenecks I see in audits.
The invokerType field on each script tells you how it was called. The full list is event-listener (clicks, scrolls, keydowns), user-callback (setTimeout, setInterval, requestAnimationFrame), resolve-promise and reject-promise (then/catch handlers), classic-script, and module-script. For INP, the ones you read first are event-listener for processing time, and user-callback, classic-script, or module-script for input delay. Promise handlers rarely top the list.
Map these fields to the three INP phases and the picture is clear. Input delay shows up as scripts running before firstUIEventTimestamp. Processing time is event-listener scripts running after the UI event. Presentation delay is everything between renderStart and the end of the frame.
Capturing LoAF in production with web-vitals.js
You do not need to build a LoAF observer from scratch. The web-vitals.js attribution build does it for you and correlates LoAF entries to actual INP measurements. This is the version I install on client sites.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
const payload = {
value: metric.value,
rating: metric.rating,
url: location.pathname,
loadState: a.loadState,
interactionTarget: a.interactionTarget,
interactionType: a.interactionType,
inputDelay: a.inputDelay,
processingDuration: a.processingDuration,
presentationDelay: a.presentationDelay,
longestScriptDuration: a.longestScript?.entry?.duration,
longestScriptInvoker: a.longestScript?.entry?.invoker,
longestScriptSource: a.longestScript?.entry?.sourceURL,
longestScriptSubpart: a.longestScript?.subpart,
longestScriptIntersecting: a.longestScript?.intersectingDuration,
};
navigator.sendBeacon('/rum', JSON.stringify(payload));
}); That payload is small enough to send without sampling. Three fields do most of the diagnostic work: longestScriptInvoker, longestScriptSource, and longestScriptSubpart. The first two tell you what ran. The third tells you which INP phase it landed in.
If you want full attribution rather than just the longest script, send a.longAnimationFrameEntries as well. Be aware that one INP measurement can include multiple LoAFs and a heavy LoAF can include ten or more script entries. Sending the full array on every page view is expensive. I usually beacon the full array only when metric.rating !== 'good'.
One important detail. The longAnimationFrameEntries array is empty when LoAF and the INP candidate interaction do not overlap, or when the browser does not support LoAF at all. Always feature-detect before treating an empty array as a clean signal.
const loafSupported = PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame'); The privacy consideration. Script sourceURL values can contain query parameters and full paths. For client work I strip everything except origin and pathname before sending to RUM. Hashed bundle filenames are useful for debugging within a deploy but useless for grouping across deploys, so I also strip the hash segment.
Attribution patterns from 925,000 URLs
CoreDash captures LoAF attribution for every INP measurement on the sites we monitor. Across our dataset of 925,000 URLs, the same handful of script categories show up as the dominant cause of blocking LoAFs over and over.
On e-commerce sites the top blocking origins are consistent. Tag managers running synchronous custom HTML tags are first. Consent management platforms doing late-binding script injection are second. Ad and bid script orchestration is third. The fourth is product recommendation widgets that hydrate large client-side. The fifth is session replay scripts that instrument every interaction.
What surprises clients is the share of forcedStyleAndLayoutDuration. On a typical poor INP frame, 30 to 60% of the longest script's duration is the script triggering synchronous layout. The script is not slow because the JavaScript is heavy. It is slow because the JavaScript reads layout (offsetHeight, getBoundingClientRect) inside a loop that also writes to the DOM. Layout thrashing. Same problem developers have been writing for fifteen years, still the largest single contributor to processing time on the sites I audit.
Across our dataset, event-listener invokers account for roughly half of the longest scripts intersecting INP. user-callback (setTimeout, setInterval, requestAnimationFrame) accounts for about a quarter. classic-script top-level execution accounts for most of what remains. Promise resolvers are rarely the longest script, which contradicts a common assumption that "async code is the cause" of INP.
The five LoAF shapes I see most often
After enough audits the LoAF entries start to look familiar. Most failing interactions match one of five shapes. Each one has a different fix.
The third-party event listener
The longest script has invokerType: "event-listener" and a sourceURL from a third-party origin. The script duration is most of the frame and forcedStyleAndLayoutDuration is low. This is a vendor tag listening to your clicks and doing too much work in the handler. The fix is rarely "remove the tag." The fix is to defer the work the tag does. For dataLayer pushes specifically there is a pattern that gets you 20 to 100ms back without breaking analytics, which I covered in Scheduling dataLayer Events to optimize the INP.
Layout thrash inside your own handler
The longest script has invokerType: "event-listener", the sourceURL is your own bundle, and forcedStyleAndLayoutDuration is more than 30% of the script duration. The script is reading layout in a loop. The fix is to batch reads before writes, or move the work to requestAnimationFrame and use cached values.
I see this most often in product filter pages on e-commerce sites. The filter handler updates the visible product count, then reads the new height of the result list to reposition a sticky filter button, then updates a CSS custom property based on that height, then reads the height again. Four forced layouts in one handler. LoAF puts that 80 to 150ms of forcedStyleAndLayoutDuration on a single script entry and the fix becomes obvious. Read once, write once, exit.
Hydration cascade on first interaction
The LoAF fires while loadState is still "loading" or "dom-interactive". firstUIEventTimestamp sits inside a frame where scripts contains framework hydration callbacks. The user clicked before the page was hydrated. The fix is not to make hydration faster, although that helps. The fix is to make the interactive elements work without hydration: native links and form submits, native disclosure widgets, native dialogs. Progressive enhancement.
Presentation delay from a heavy DOM
This one looks different in the data. blockingDuration is low. The scripts array is short. But the gap between styleAndLayoutStart and the end of the frame is more than 100ms. No JavaScript fix will help. The renderer is choking on style recalculation across thousands of nodes. Use content-visibility: auto on offscreen sections, contain: layout style on heavy components, and reduce the size of the style scope that has to recalculate per interaction.
The rAF storm
Multiple LoAFs chain together, each with a user-callback script invoked by Window.requestAnimationFrame. The page is running a JavaScript animation that competes with the user. JavaScript-driven scroll behavior is the most common version. I have seen sites add 200ms of presentation delay to every click on every page because of one smooth-scroll polyfill nobody remembers shipping. I covered the scroll case in Improve the INP by ditching JavaScript scrolling. Same logic for any JavaScript animation: switch to CSS or the Web Animations API and the LoAFs disappear.
What a LoAF payload looks like in the field
Here is a real LoAF entry from a checkout page audit. I have anonymized the URLs but the structure and timings are intact.
{
"name": "long-animation-frame",
"entryType": "long-animation-frame",
"startTime": 5902,
"duration": 320,
"blockingDuration": 268,
"renderStart": 6170,
"styleAndLayoutStart": 6172,
"firstUIEventTimestamp": 5913,
"scripts": [
{
"name": "script",
"entryType": "script",
"invoker": "BUTTON#checkout-submit.onclick",
"invokerType": "event-listener",
"sourceURL": "https://cdn.example.com/app.HASH.js",
"sourceFunctionName": "handleCheckoutSubmit",
"duration": 248,
"forcedStyleAndLayoutDuration": 142,
"executionStart": 5914,
"startTime": 5914
}
]
} Read it like this. The frame started at 5902ms after page load. At 5913ms the user clicked the checkout submit button. The click handler started at 5914ms and ran for 248ms. Of those 248ms, 142ms was forced synchronous layout. The renderer did not get to start until 6170ms, more than a quarter second after the click. INP for this interaction is around 270ms, in the "needs improvement" zone.
The fix is not "make handleCheckoutSubmit faster." The fix is "stop forcing layout inside handleCheckoutSubmit." 142ms out of 248ms is not the JavaScript. It is reading layout values that the JavaScript writes have invalidated. On the real engagement this came from, caching the read values once before the write loop dropped script duration from 248ms to 110ms. INP on the checkout page moved from p75 270ms to under 200ms. The page passed.
That is the kind of fix that does not show up in any lab tool. Lighthouse cannot tell you about forcedStyleAndLayoutDuration because Lighthouse does not interact with the page. LoAF in the field is the only way to see it.
Driving an AI agent with LoAF data
The reason LoAF matters for AI-assisted debugging is that it gives an agent something concrete to reason about. An agent without field data can guess at INP causes from your code. An agent with LoAF entries from real user sessions can name the script, the function, and the phase. The diagnosis stops being a guess.
The workflow I use with Claude Code: pull the worst INP page from CoreDash via the MCP server, attach the LoAF entries for that URL's slowest interactions, and ask the agent to identify which of the five shapes above each entry matches. The agent classifies each LoAF, points to the function in the codebase, and proposes a fix. I review the fix. I apply it. CoreDash measures whether p75 INP moves.
This works because LoAF data is structured and small. A single LoAF JSON payload fits in a fraction of an agent's context window. A handful of representative LoAFs for a single page is enough to drive a fix. I wrote about the broader pattern in Fix INP with an AI agent: the metric lab tools cannot measure and AI Agent Core Web Vitals: why field data changes everything.
Cross-browser reality
LoAF is Chromium-only. Safari does not implement it. Firefox does not implement it. With INP itself now shipping in Safari 26.2 and Firefox 144, you have a measurement gap. Your INP RUM data is cross-browser. Your attribution data is Chrome and Edge.
I see clients hesitate to adopt LoAF because of this. They want to wait until Safari ships it. That is the wrong call. Chrome is where most performance bugs are first reproduced and most performance fixes are first validated. The shape of the problem (long event handler, layout thrash, hydration cascade) does not change between browsers. Only the diagnostic lens differs. Fixes you ship based on Chromium LoAF data improve INP for Safari and Firefox users too.
If you want some kind of attribution in Safari today, EventTiming is what you have. It tells you which event was slow but not which script. That is enough to know where to look but not what to fix. For Safari attribution, lab profiling in Safari Web Inspector is the practical fallback.
What LoAF still cannot tell you
Three blind spots are worth knowing about because they will catch you out at some point.
The first is cross-origin iframes. LoAF only attributes scripts running on the main thread of windows that share the document's origin. A blocking script inside a third-party iframe shows up as opaque attribution: a long frame and an empty scripts array. The fix is the same as for any third-party iframe performance problem: defer the iframe load, give it explicit dimensions, and use loading="lazy" if it is below the fold.
Workers are the second blind spot. Web workers do not appear in LoAF entries because LoAF is a main-thread API. If your INP is bad because a worker is sending heavy messages back to the main thread, LoAF shows you the message handler on the main thread but not the worker work that triggered it. You cross-reference manually using performance marks inside the worker.
And finally, GPU compositor delay. The end of a LoAF entry is when the renderer finishes its work on the main thread. The actual pixels-on-screen moment is later, in the compositor and the GPU. On low-end Android devices the compositor delay can add 30 to 100ms that LoAF does not see. The presentationTime field on LoAF entries (currently behind the experimental PaintTimingMixin flag) is meant to address this but is not yet stable across Chrome versions.
None of these are fatal. They just mean LoAF is not the whole story. For 80% of INP problems on real client sites, it is enough.
Where to start
If you have never looked at LoAF data before, start in the field, not the lab. Install the web-vitals.js attribution build, beacon the longest script per INP measurement, and look at the top ten longestScriptSource values across a week of traffic. Whatever sits at the top is your first fix, regardless of what your Lighthouse report tells you.
If you want the lab view, the Chrome DevTools Performance panel does not show LoAF natively but the data is available via PerformanceObserver. The custom track approach using performance.measure with the devtools detail field surfaces LoAF entries inside the panel. The webperf-snippets project has a console snippet that prints a summary table of LoAF entries with interactions, which is what I usually run first when debugging a specific page.
Explore the rest of the INP series
The LoAF playbook fits inside the larger workflow. To go deeper on each phase or step:
- Find and fix INP issues: the diagnostic flow from RUM data to the slow interaction.
- Input delay: scripts running before the event handler can start.
- Processing time: the event handler itself.
- Presentation delay: the rendering work after the handler returns.
- INP hub: the full guide to the metric.
Sources: Chrome for Developers, Long Animation Frames API, MDN, Long animation frame timing, W3C Long Animation Frames specification, GoogleChrome/web-vitals library.
Your Lighthouse score is not the full picture.
Your real users are on Android phones over 4G. I analyze what they actually experience.
Analyze field dataYour Long Animation Frames API Questions Answered
Browser support and what LoAF replaces
Is the Long Animation Frames API supported in all browsers?
No. LoAF is Chromium-only. Chrome, Edge, Opera, and Brave have it. Safari and Firefox do not. Always feature-detect with PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') before relying on it.
Does LoAF replace the Long Tasks API?
For INP debugging, yes. Long Tasks gives you main-thread blocking duration with no script attribution. LoAF gives you the same blocking signal plus the actual script that ran. There is no reason to use Long Tasks for new INP work. Total Blocking Time may eventually be redefined in terms of LoAF blockingDuration.
Reading LoAF data
How do I correlate a LoAF entry with a specific INP measurement?
The web-vitals.js attribution build does this for you via attribution.longAnimationFrameEntries. It finds the LoAF entries whose timing window overlaps the INP candidate interaction. If you are rolling your own observer, compare entry.startTime and entry.startTime + entry.duration against the interaction time, and include any LoAF whose window intersects.
What does forcedStyleAndLayoutDuration actually mean?
It is the time the script spent triggering synchronous layout. When your JavaScript reads offsetHeight, getBoundingClientRect, or any other property that requires up-to-date layout after a DOM mutation, the browser has to do layout work synchronously inside the script. That work is attributed back to the script. High forcedStyleAndLayoutDuration is almost always layout thrashing.
Why is the scripts array empty for some LoAF entries?
Three reasons. First, the entry only contains scripts that ran for at least 5ms. A frame full of short scripts under that threshold will show no attribution. Second, scripts running inside cross-origin iframes, web workers, service workers, or browser extensions are not attributed because LoAF only sees the same-origin main thread. Third, the frame may have no script work at all, only style and layout, which is a presentation-delay frame.
Beyond INP
Can LoAF help with LCP, not just INP?
Yes, indirectly. A LoAF that runs before the LCP element renders is a long script delaying LCP render. Filter LoAFs to those whose end time is before the LCP timestamp, and the longest script there is the largest contributor to LCP render delay. The same attribution data, applied to a different metric.