
Stop Measuring Long Tasks: Why the Long Animation Frames API Is the Only Way to Finally Rescue Your INP
The old way of tracking performance is blind to the real causes of UI jank—learn how the Long Animation Frames (LoAF) API provides the surgical precision needed to fix unresponsive interactions.
I used to spend hours staring at "Long Task" entries in my performance monitoring dashboard, feeling like a detective trying to solve a crime with a blurred grainy photo. I’d see a 400ms task, identify that it happened on the main thread, and then... nothing. I had no idea which script triggered it, whether it was a React re-render, a third-party tracking pixel, or a messy setTimeout from a library I didn’t even know we were using. The "Long Task" told me *that* the UI froze, but it never told me *why*.
That frustration is finally over. The Long Animation Frames (LoAF) API has fundamentally changed how we diagnose Interaction to Next Paint (INP). If you are still relying on the old Long Tasks API to improve your web performance, you are essentially flying blind.
The Blind Spot of Long Tasks
To understand why we need LoAF, we have to acknowledge the failure of the Long Task. By definition, a Long Task is any execution on the main thread that exceeds 50ms.
The problem is that the Long Tasks API is task-centric, not frame-centric.
Web performance is ultimately about how quickly the browser can produce a frame to show the user. A single frame might involve multiple scripts, a layout pass, and a paint. A Long Task might capture one of those scripts, but it misses the "whitespace" between tasks and the rendering overhead that actually causes the delay the user feels.
Even worse, Long Tasks give you almost zero attribution. You might get the "container" (like an iframe), but you won't get the specific function or file responsible. You’re left guessing.
Enter Long Animation Frames (LoAF)
The Long Animation Frames API (available from Chrome 123) shifts the focus. Instead of looking for long-running scripts, it looks for frames that took too long to render (specifically, those exceeding 50ms).
It provides a holistic view of the main thread. It doesn’t just say "this script was slow." It says: "This frame took 200ms to appear. Here is the breakdown of every script that ran, how long the styles and layout took, and exactly which event listener triggered the whole mess."
The LoAF Structure
A LoAF entry gives you a breakdown that looks something like this:
1. Duration: The total time from the start of the frame to the paint.
2. Scripts: An array of every script execution that contributed to the delay.
3. Style/Layout/Commit: The "rendering" time that was previously invisible.
Here is the most basic way to start seeing this data in your console:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('--- Long Animation Frame Detected ---');
console.log(`Total Duration: ${entry.duration}ms`);
console.log(`Render Duration: ${entry.renderStart - entry.startTime}ms`);
console.log(`Scripts involved:`, entry.scripts);
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });Why LoAF is the "INP Killer"
Interaction to Next Paint (INP) measures the time from a user interaction (like a click) to the moment the next frame is actually painted on the screen.
When your INP is high, it’s usually because of one of three things:
1. Input Delay: The main thread was busy when the user clicked.
2. Processing Time: Your event handler took too long.
3. Presentation Delay: The browser spent too much time recalculating styles and painting the page *after* your code finished.
The Long Tasks API only helps with a portion of #2. LoAF covers all three.
The Smoking Gun: Script Attribution
This is where it gets surgical. LoAF provides a scripts array. Each object in that array tells you exactly what happened.
// A conceptual look at a LoAF script entry
{
"duration": 120,
"invoker": "BUTTON#submit-btn.onclick",
"invokerType": "event-listener",
"sourceLocation": "https://cdn.example.com/main.js:45:12",
"forcedStyleAndLayoutDuration": 30
}Look at that sourceLocation. Look at the invoker. It tells you that the onclick handler on your submit button was the culprit. It even tells you if that specific script forced a synchronous layout (layout thrashing), which is a common performance killer that Long Tasks completely ignored.
Practical Example: Finding the Root Cause of a Laggy Click
Let's say you have a button that feels "heavy." You click it, and the UI freezes for half a second. In the past, you'd use the Profiler and hope to catch it. With LoAF, you can automate this discovery.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// We only care about really bad frames (e.g., > 200ms)
if (entry.duration > 200) {
entry.scripts.forEach((script) => {
const { invoker, duration, sourceLocation } = script;
// Log the "who" and the "how long"
console.warn(`[LoAF Warning]
Script took ${duration}ms.
Triggered by: ${invoker}
Location: ${sourceLocation}
`);
// If it's a 3rd party script, we can flag it specifically
if (sourceLocation && !sourceLocation.includes(window.location.hostname)) {
console.error('Potential 3rd party performance culprit identified!');
}
});
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });Correlating LoAF with Real User Interactions
The real power of LoAF comes when you tie it directly to an INP event. Since INP and LoAF both use the same timeline, you can "bridge" them. If a user clicks a button and the interaction is slow, you can look for the LoAF entry that overlaps with that interaction's timestamp.
Here is a simplified pattern for connecting an interaction to a LoAF entry:
let lastInteractionTime = 0;
// Track when interactions happen
['mousedown', 'keydown', 'pointerdown'].forEach(type => {
window.addEventListener(type, () => {
lastInteractionTime = performance.now();
}, { passive: true });
});
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const frameStart = entry.startTime;
const frameEnd = entry.startTime + entry.duration;
// Check if the interaction happened during this long frame
if (lastInteractionTime >= frameStart && lastInteractionTime <= frameEnd) {
console.log("Found the LoAF responsible for the slow interaction!");
console.table(entry.scripts.map(s => ({
type: s.invokerType,
source: s.sourceLocation,
duration: s.duration
})));
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });In a production environment, you wouldn't just console.log this. You would bundle this metadata and send it to your analytics endpoint. Instead of seeing "INP: 450ms," your dashboard would show "INP: 450ms caused by handleTagManagerClick in gtm.js." That is the difference between guessing and knowing.
The Hidden Culprit: Presentation Delay
One of the biggest "aha!" moments I had with LoAF was realizing how much time was being spent *after* my code finished.
In the Long Tasks world, if your script ran for 20ms, the task was considered "fast" (under the 50ms threshold). But if that 20ms script changed a CSS class that triggered a massive re-layout of 2,000 DOM nodes, the user might see a 150ms delay before the screen actually updates.
LoAF captures this as styleAndLayoutDuration.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const renderTime = entry.styleAndLayoutDuration;
if (renderTime > 50) {
console.warn(`Expensive rendering detected: ${renderTime}ms. Check for DOM size or complex CSS.`);
}
}
});I've seen apps where the JavaScript execution was actually quite optimized, but the "Presentation Delay" was killing the INP because of excessive DOM depth. Long Tasks would never have shown me that.
Strategies for Implementation
You don't want to collect every single LoAF entry. That’s too much data. Focus on the outliers.
1. Filter by Duration
Only report frames that exceed a certain threshold (e.g., 200ms) or those that overlap with a recorded event entry with high latency.
2. Identify "Known Offenders"
I often use LoAF to categorize scripts into "Core Bundle," "Third Party," or "Extension." Chrome extensions often inject scripts that mess with your site’s performance. LoAF is one of the few ways to actually prove that an extension is the reason a user is experiencing jank.
3. Track blockingDuration
LoAF entries include a blockingDuration property. This is the sum of the time within the frame where the main thread was "blocked" (tasks longer than 50ms). This is a great high-level metric to track alongside the total frame duration.
The Gotchas
As much as I love this API, there are a few things that caught me off guard:
1. Browser Support: Currently, this is a Chromium-only feature. You still need fallbacks for Safari and Firefox, though they usually don't support the level of attribution we need anyway.
2. Privacy: To prevent timing attacks, script source locations are sometimes sanitized or hidden if they are cross-origin and don't have the proper CORS headers. If you see sourceLocation as empty, check your Timing-Allow-Origin headers.
3. Overhead: While PerformanceObserver is designed to be low-impact, processing complex LoAF entries with dozens of scripts *can* add its own overhead. Keep your observer callback lean. Don't do heavy data processing inside it; push the raw data to a queue and process it during an requestIdleCallback.
A New Mental Model for Performance
We need to stop thinking about performance as a series of isolated scripts. The user doesn't care about scripts; they care about frames.
The Long Animation Frames API forces us to look at the entire lifecycle of a frame. It bridges the gap between the "Code" (what we write) and the "Browser" (what the user sees).
If you're serious about fixing INP, stop looking at Long Tasks. Set up a LoAF observer today. Find the scripts that are actually hogging the main thread. See the layout shifts that are happening behind the scenes. And finally, give your users the fluid, responsive experience they expect.
The forensic evidence is there. You just have to start looking at the right frames.

