Why Debugging INP Is Hard — and How LoAF Changes Everything
You've already optimized your event handlers, yielded to the main thread, and maybe even moved expensive work to Web Workers. Yet your Interaction to Next Paint (INP) score is still stubbornly sitting above 200 ms in the field. The problem isn't that you lack fixes — it's that you lack visibility. The older Long Tasks API tells you something was slow, but not what, not where, and certainly not why.
That's where the Long Animation Frames API comes in. LoAF (pronounced "lo-af") fills that gap with per-script attribution, rendering-phase breakdowns, and direct correlation with INP interactions. Shipped in Chrome 123 back in March 2024, it's now supported across all Chromium-based browsers in 2026. This tutorial walks you through everything you need to start using LoAF in production — from your first PerformanceObserver call to a full field-data reporting pipeline.
What Is a Long Animation Frame?
A long animation frame is any rendering update cycle — from when the browser starts processing tasks through style, layout, paint, and compositing — that exceeds 50 milliseconds. Unlike the Long Tasks API, which measures individual tasks in isolation, LoAF measures the entire animation frame. And that distinction matters more than you'd think.
Here's a scenario that catches people off guard: your page runs 12 small event callbacks of 8 ms each during a single frame. No single task triggers a Long Task entry, yet the frame takes 96 ms — well above the 50 ms threshold. The Long Tasks API sees nothing. LoAF catches it immediately.
LoAF vs. Long Tasks API: Key Differences
Understanding why LoAF exists really comes down to understanding what the Long Tasks API gets wrong:
- Unit of measurement: Long Tasks measures individual tasks; LoAF measures entire animation frames including rendering work.
- Cumulative impact: Long Tasks misses frames made slow by many short tasks; LoAF captures them.
- Script attribution: Long Tasks gives you a container name (not very helpful, honestly). LoAF identifies the exact script URL, function name, and character position.
- Rendering breakdown: Long Tasks has zero visibility into style/layout costs; LoAF includes
renderStart,styleAndLayoutStart, and detailed timing for each rendering phase. - INP correlation: LoAF entries can be directly linked to INP interactions by comparing timestamps, making it the go-to debugging tool for responsiveness issues.
Setting Up Your First LoAF Observer
Alright, let's get into the actual code. Observing long animation frames uses a standard PerformanceObserver. The entry type is "long-animation-frame":
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LoAF detected:', {
duration: entry.duration,
blockingDuration: entry.blockingDuration,
renderStart: entry.renderStart,
styleAndLayoutStart: entry.styleAndLayoutStart,
scripts: entry.scripts.length,
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
The buffered: true option is critical here — it retrieves any LoAF entries that fired before your observer was registered. Without it, you'll miss frames during page load, which are often the worst offenders.
Feature Detection
Since LoAF is only available in Chromium browsers as of 2026, always feature-detect before observing:
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
// Safe to observe LoAF
const observer = new PerformanceObserver(handleLoAFEntries);
observer.observe({ type: 'long-animation-frame', buffered: true });
}
Anatomy of a LoAF Entry
Each PerformanceLongAnimationFrameTiming entry gives you a surprisingly rich set of properties. Here's what each one tells you:
startTime: When the animation frame began (relative totimeOrigin).duration: Total wall-clock time from start to the end of rendering.blockingDuration: The portion of the frame where the main thread was blocked from responding to high-priority input. This is the number most directly tied to INP — pay close attention to it.renderStart: When rendering callbacks (requestAnimationFrame,ResizeObserver,IntersectionObserver) began executing.styleAndLayoutStart: When style recalculation and layout began.firstUIEventTimestamp: The timestamp of the first user input event (click, keypress, etc.) processed during this frame. If it's non-zero, the frame handled a user interaction — making it a candidate for your INP entry.scripts: An array ofPerformanceScriptTimingobjects attributing each script that ran during the frame.
Calculating Phase Durations
The raw timestamps let you calculate exactly where time was spent inside a frame:
function breakdownLoAF(entry) {
const scriptDuration = entry.scripts.reduce(
(sum, s) => sum + s.duration, 0
);
const renderingDuration = entry.styleAndLayoutStart - entry.renderStart;
const layoutDuration = entry.startTime + entry.duration
- entry.styleAndLayoutStart;
return {
total: entry.duration,
blocking: entry.blockingDuration,
scripts: scriptDuration,
renderCallbacks: renderingDuration,
styleAndLayout: layoutDuration,
};
}
This breakdown immediately tells you whether you're dealing with a script problem (heavy JavaScript), a rendering callback problem (expensive requestAnimationFrame or observer callbacks), or a layout problem (complex DOM triggering costly style/layout recalculations). I've found that most INP issues in the wild fall into the first category, but you'd be surprised how often layout costs sneak up on you.
Script Attribution: Finding the Exact Culprit
The scripts array is where LoAF truly shines. Each PerformanceScriptTiming entry tells you:
name: How the script was invoked (e.g.,IMG#hero.onload,BUTTON#submit.onclick,TimerHandler:setTimeout).invoker: The invoker string, including event target selectors for event handlers.invokerType: The type of callback —"user-callback","event-listener","resolve-promise", or"classic-script".sourceURL: The URL of the script file.sourceFunctionName: The function name within the script.sourceCharPosition: The character position in the source file — pinpointing the exact line of code responsible.duration: How long this specific script execution took.executionStart: When this script began executing.forcedStyleAndLayoutDuration: Time spent in forced synchronous layout triggered by this script (a.k.a. layout thrashing).
Here's a practical example that logs the top offending scripts from every LoAF:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Sort scripts by duration, longest first
const sorted = [...entry.scripts].sort(
(a, b) => b.duration - a.duration
);
for (const script of sorted.slice(0, 3)) {
console.log('Slow script:', {
invoker: script.invoker,
invokerType: script.invokerType,
sourceURL: script.sourceURL,
functionName: script.sourceFunctionName,
charPosition: script.sourceCharPosition,
duration: Math.round(script.duration),
forcedLayout: Math.round(script.forcedStyleAndLayoutDuration),
});
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Linking LoAF to INP Interactions
Here's where it gets really useful. The real power of LoAF is diagnosing why a specific interaction was slow. There's no direct API to link an INP entry with its LoAF, but you can correlate them by comparing timestamps. The web-vitals library (v4+) handles this for you automatically.
Method 1: Using the web-vitals Library (Recommended)
The attribution build of the web-vitals library exposes LoAF data directly on INP reports:
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const { attribution } = metric;
console.log('INP value:', metric.value, 'ms');
console.log('INP phases:', {
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
});
// LoAF entries that overlapped this interaction
const loafs = attribution.longAnimationFrameEntries;
for (const loaf of loafs) {
console.log('Overlapping LoAF:', {
duration: loaf.duration,
blockingDuration: loaf.blockingDuration,
scripts: loaf.scripts.map((s) => ({
invoker: s.invoker,
sourceURL: s.sourceURL,
duration: s.duration,
})),
});
}
}, { reportAllChanges: true });
Setting reportAllChanges: true ensures you get data on every interaction, not just the final worst one reported at page unload.
Method 2: Manual Timestamp Correlation
If you can't use the web-vitals library (maybe you've got constraints around third-party dependencies), you can link LoAFs to interactions manually:
// Collect all LoAF entries
const loafEntries = [];
const loafObserver = new PerformanceObserver((list) => {
loafEntries.push(...list.getEntries());
});
loafObserver.observe({ type: 'long-animation-frame', buffered: true });
// When you detect an INP-like interaction via Event Timing:
const eventObserver = new PerformanceObserver((list) => {
for (const event of list.getEntries()) {
if (event.duration < 100) continue; // Only slow interactions
const eventStart = event.startTime;
const eventEnd = event.startTime + event.duration;
// Find overlapping LoAFs
const overlapping = loafEntries.filter((loaf) => {
const loafEnd = loaf.startTime + loaf.duration;
return loaf.startTime < eventEnd && loafEnd > eventStart;
});
console.log('Slow interaction:', event.name, event.duration, 'ms');
console.log('Overlapping LoAFs:', overlapping);
}
});
eventObserver.observe({ type: 'event', buffered: true });
Identifying Third-Party Script Impact
This is honestly one of the most valuable things you can do with LoAF — figuring out how much of your INP pain is actually coming from third-party scripts. The sourceURL property makes it straightforward to classify every script execution:
function classifyScripts(loafEntry, ownDomain) {
const firstParty = [];
const thirdParty = [];
for (const script of loafEntry.scripts) {
if (!script.sourceURL) {
// No URL = cross-origin script without proper headers
thirdParty.push({ ...script, note: 'missing attribution' });
} else if (new URL(script.sourceURL).hostname === ownDomain) {
firstParty.push(script);
} else {
thirdParty.push(script);
}
}
return {
firstPartyTime: firstParty.reduce((s, sc) => s + sc.duration, 0),
thirdPartyTime: thirdParty.reduce((s, sc) => s + sc.duration, 0),
thirdPartyScripts: thirdParty.map((s) => ({
url: s.sourceURL || '(unknown)',
duration: s.duration,
invoker: s.invoker,
})),
};
}
I've run this on multiple client sites and it's not uncommon to find that 40-60% of blocking time comes from third-party scripts — analytics, ad tech, chat widgets, you name it.
Fixing Missing Third-Party Attribution
If you're seeing empty sourceURL values for third-party scripts, that's typically a cross-origin restriction. Two steps fix it:
- Add the
crossorigin="anonymous"attribute to the<script>tag loading the third-party resource. - Ensure the third-party server returns the
Access-Control-Allow-Origin: *response header.
For Google Tag Manager specifically, you'll need to modify the GTM snippet to include cross-origin loading:
<!-- Modified GTM snippet with crossorigin for LoAF attribution -->
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.crossOrigin='anonymous';
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXX');
</script>
Building a LoAF Reporting Pipeline
Logging LoAFs to the console is great during development, but the real value comes from collecting field data across your actual users. Here's a production-ready reporting function that won't overwhelm your backend:
function initLoAFReporting(endpoint, options = {}) {
const {
threshold = 100, // Only report frames above this duration
sampleRate = 0.1, // Report 10% of sessions
maxEntries = 20, // Cap entries per page to limit bandwidth
} = options;
// Sampling: only report for a fraction of sessions
if (Math.random() > sampleRate) return;
let reportedCount = 0;
if (!PerformanceObserver.supportedEntryTypes
?.includes('long-animation-frame')) {
return; // LoAF not supported
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < threshold) continue;
if (reportedCount >= maxEntries) {
observer.disconnect();
return;
}
const payload = {
url: location.href,
timestamp: new Date().toISOString(),
duration: Math.round(entry.duration),
blockingDuration: Math.round(entry.blockingDuration),
hadInteraction: entry.firstUIEventTimestamp > 0,
scripts: entry.scripts.slice(0, 5).map((s) => ({
invoker: s.invoker,
invokerType: s.invokerType,
sourceURL: s.sourceURL,
duration: Math.round(s.duration),
forcedLayout: Math.round(s.forcedStyleAndLayoutDuration),
})),
};
// Use sendBeacon for reliable delivery
navigator.sendBeacon(
endpoint,
JSON.stringify(payload)
);
reportedCount++;
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
}
Key Design Decisions
- Threshold filtering: Start at 100 ms or even 150 ms to cut down on noise. You can gradually lower the threshold as you fix the worst offenders.
- Session sampling: Reporting every LoAF from every user generates enormous data volume. A 1–10% sample rate is standard for production RUM.
- Entry cap: Pages with severe performance issues can generate hundreds of LoAFs. Capping at 20–50 per page prevents bandwidth abuse — trust me, your analytics team will thank you.
sendBeacon: Unlikefetch,sendBeaconis guaranteed to deliver data even when the page is being unloaded. This is critical for capturing the worst-case INP interaction, which often happens just before the user navigates away.
Real-World Debugging Workflow
So, let's put it all together. Here's the step-by-step workflow I'd recommend for diagnosing a slow INP score using LoAF data:
Step 1: Identify the Worst Interaction
Use CrUX data or your RUM tool to identify which pages and interaction types (click, keypress, tap) have the highest INP values at the 75th percentile.
Step 2: Reproduce and Capture LoAFs Locally
Open the problem page in Chrome, paste the LoAF observer snippet into the console, and interact with the element reported as slow. The console output immediately shows you which scripts ran during the slow frame. It's surprisingly satisfying when the culprit jumps right out at you.
Step 3: Check the INP Phase Breakdown
Using the web-vitals library attribution data, figure out which INP phase is the bottleneck:
- High input delay: Other scripts were already running when the user interacted. Look at LoAF scripts that started before the interaction event — those are the ones blocking input processing.
- High processing duration: Your event handlers themselves are slow. The LoAF scripts with
invokerType: "event-listener"matching your interaction element are the culprits. - High presentation delay: The frame took a long time after your handlers finished. Check
styleAndLayoutStartand the difference between that and the frame end — heavy DOM mutations triggering expensive layout are the usual cause.
Step 4: Check for Layout Thrashing
If any LoAF script entry shows a high forcedStyleAndLayoutDuration, that script is reading layout properties (like offsetHeight or getBoundingClientRect()) after making DOM changes, forcing the browser to recalculate layout synchronously. The fix? Batch your DOM reads and writes — always read first, then write.
Step 5: Quantify Third-Party Impact
Use the script classification function from earlier to calculate what percentage of the LoAF duration comes from third-party code. If third parties dominate, you've got a few options:
- Loading them with
asyncordeferattributes - Moving them to a Web Worker with a library like Partytown
- Delaying their initialization until after the first user interaction
- Replacing heavy third-party widgets with lighter alternatives
Step 6: Validate the Fix
After applying your fix, lower your LoAF reporting threshold to capture frames down to 50 ms. Deploy to a canary group and compare the before and after data. Specifically watch for:
- Reduction in
blockingDurationat the 75th percentile - Fewer LoAFs with
firstUIEventTimestamp > 0(frames that handled interactions) - Improvement in CrUX INP data within the next 28-day collection cycle
Common Pitfalls and Tips
Don't Report Every LoAF
A busy page can generate dozens of LoAFs per second during heavy interaction. Without sampling and threshold filtering, you'll overwhelm your analytics endpoint and burn through your RUM budget fast. Always start with a high threshold and a low sample rate.
Focus on blockingDuration, Not Just duration
Here's a nuance that trips people up. A LoAF with a 200 ms duration but only 10 ms of blockingDuration is far less impactful on INP than one with 120 ms duration and 100 ms blockingDuration. The blocking duration represents the time the browser literally cannot respond to input — that's what users actually feel.
Don't Ignore LoAFs Without Interactions
A common mistake is filtering LoAFs to only those with firstUIEventTimestamp > 0. Non-interaction LoAFs reveal scripts that could cause future INP issues if a user happens to interact during their execution. Proactively fixing these prevents problems before they show up in CrUX data.
Browser Support Is Chromium-Only (For Now)
As of April 2026, LoAF is supported in Chrome 123+, Edge 123+, and Opera 109+. Firefox and Safari haven't implemented it yet. Your LoAF field data will only reflect Chromium users — but given Chrome's ~65% global market share, that's still a representative sample for most sites.
Frequently Asked Questions
What is the difference between the Long Animation Frames API and the Long Tasks API?
The Long Tasks API measures individual JavaScript tasks exceeding 50 ms but provides no script attribution and misses frames made slow by many short tasks. The Long Animation Frames API (LoAF) measures entire rendering frames, includes detailed script-level attribution (URL, function name, character position), provides rendering phase breakdowns, and captures cumulative task impact within a single frame. LoAF is the recommended tool for diagnosing INP issues in 2026.
Does the LoAF API work in Firefox and Safari?
Not yet. As of April 2026, the Long Animation Frames API is only available in Chromium-based browsers (Chrome 123+, Edge 123+, Opera 109+). Firefox and Safari haven't shipped it. That said, since Chromium powers roughly 65% of global browser usage, LoAF field data still gives you a statistically meaningful view of your site's responsiveness for the majority of visitors.
How do I connect a LoAF entry to a specific user interaction?
The easiest way is to use the web-vitals library (v4 or later) with its attribution build. It automatically links overlapping LoAF entries to INP interactions via the longAnimationFrameEntries property. Alternatively, you can manually correlate them by checking whether a LoAF entry's time range overlaps with an Event Timing entry's time range.
Why do some LoAF script entries have empty sourceURL values?
Empty sourceURL values typically mean the script was loaded cross-origin without proper CORS headers. The browser withholds attribution details for security reasons. To fix this, add crossorigin="anonymous" to the <script> tag and ensure the server returns the Access-Control-Allow-Origin: * header.
Should I replace the Long Tasks API with LoAF entirely?
Not necessarily. While LoAF is superior for most use cases, the Long Tasks API still works as a simple, lightweight signal for detecting main-thread contention — especially if you need broader browser compatibility. There are no plans to deprecate it. But for any serious INP debugging or third-party script analysis, LoAF is what you should reach for.