
A Precise Record of the Main Thread’s Absence
If you're tired of the Long Tasks API telling you that your UI stalled without explaining why, it's time to look at the script-level attribution provided by the Long Animation Frame API.
Browsers have a habit of being vague just when you need them to be specific. For years, we’ve relied on the Long Tasks API to tell us when the main thread has frozen, and for years, it has responded with the technical equivalent of a shrug. It tells you the main thread was busy for 150ms, but when you ask "Doing what?", the API points vaguely at the window and says, "Oh, you know... stuff."
If you’ve ever tried to debug a performance regression in production using only Long Tasks data, you know the frustration. You see the spike in your dashboard, you see the "long-task" entry, but you have no idea if it was your heavy data-processing logic, a third-party chat widget, or a stray mouseover event from a library you forgot you installed. The Long Animation Frame (LoAF) API is the answer to this specific, recurring headache. It moves us from knowing *that* the UI stalled to knowing exactly *why* it stalled.
The Blind Spots of Long Tasks
To understand why LoAF is such a leap forward, we have to look at what the Long Tasks API gets wrong. A "Long Task" is defined as any continuous period where the main thread is occupied for more than 50ms.
The problem is that a single "Long Task" rarely represents the totality of a user's perceived delay. A user interaction might trigger a script, which triggers a promise, which triggers a style recalculation, which triggers a paint. The Long Tasks API sees these as disconnected chunks. More importantly, it provides almost zero attribution. You get a duration and a start time. That’s it.
If you’re lucky, you might get a containerType or containerSrc, but in the era of complex SPAs and bundled JavaScript, telling me the task happened in "window" is like a detective telling me the crime happened "on Earth."
What Makes LoAF Different?
The Long Animation Frame API shifts the perspective. Instead of looking at isolated tasks, it looks at the render cycle.
A Long Animation Frame is defined as any frame that takes longer than 50ms to render. The magic happens in the scripts property of the LoAF entry. For the first time, the browser provides a detailed list of every script that contributed to that delay.
It tells you:
- Which script ran (the source URL).
- Which function was called.
- What triggered the script (an event listener, a timer, a promise).
- How long that specific script took to execute.
This is the "Precise Record" we’ve been missing.
Observing Your First LoAF
You don't need a complex setup to start seeing this data. You can observe it via a PerformanceObserver just like you would for LCP or CLS.
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(`Style/Layout Duration: ${entry.styleAndLayoutStart - entry.renderStart}ms`);
// This is the gold mine
entry.scripts.forEach((script) => {
console.log({
source: script.sourceLocation,
duration: script.duration,
type: script.invokerType, // e.g., 'event-listener' or 'timer'
function: script.invoker
});
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });The buffered: true flag is important here. It ensures that any long frames that occurred before your script initialized are still processed. If you run this in your console on a heavy site, you’ll immediately see the difference in granularity.
Breaking Down the Attribution
Let’s talk about the scripts array in that code snippet. This is where the real value lies. Each object in that array represents a "contribution" to the frame delay.
The invokerType tells you the "how." Was it a setTimeout? A Promise.then? An XMLHttpRequest callback? Knowing this allows you to stop guessing. If you see 300ms of delay and the invokerType is user-callback for a click event, you know exactly which UI component is to blame.
One of the most useful fields is sourceLocation. In a production environment where your code is minified, this might look like https://cdn.example.com/main.a8f2b3.js:1:15024. While not immediately readable by a human, this is a dream for automated error-tracking services. You can pipe this data back to your telemetry and map it to your source maps.
A Practical Example: The Third-Party Culprit
Suppose you have a marketing script that occasionally hangs the main thread. Using the old API, you’d just see a "Long Task." Using LoAF, you can write a filter to identify exactly when your code is being held hostage by external dependencies.
const THRESHOLD = 100; // Only care about frames over 100ms
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < THRESHOLD) continue;
const thirdPartyScripts = entry.scripts.filter(s => {
// Check if the script URL doesn't match your domain
return s.sourceURL && !s.sourceURL.includes('my-awesome-app.com');
});
if (thirdPartyScripts.length > 0) {
console.warn(`Blocked by third-party for ${entry.duration}ms`);
thirdPartyScripts.forEach(s => {
console.table({
Provider: s.sourceURL,
Delay: s.duration,
Action: s.invoker
});
});
}
}
}).observe({ type: 'long-animation-frame', buffered: true });This changes the conversation with stakeholders. Instead of saying "The site feels slow," you can say "The customer feedback widget is blocking the main thread for 200ms every time the page loads."
Understanding the "Work" vs. the "Wait"
LoAF doesn't just measure script execution. It measures the entire lifecycle of a frame. This includes:
1. Task Duration: The time spent running scripts.
2. Render Delay: The time between the end of the last task and the start of the rendering (style/layout/paint).
3. Style and Layout: The time the browser spent calculating where things go.
Often, we blame our JavaScript when the real culprit is a massive DOM tree that causes a 100ms Style/Layout phase. LoAF exposes this. If the entry.duration is high but the sum of entry.scripts[].duration is low, you know your problem isn't your logic—it's your CSS or your DOM structure.
Identifying Blocking Event Listeners
One of the trickiest things to profile is "Input Delay." You click a button, and nothing happens for half a second. LoAF gives us a specific window into this via the invoker property.
When a script is triggered by an event, the invoker property will contain the name of the event and the element it was attached to (e.g., BUTTON.onclick).
const logInputStalls = (entry) => {
entry.scripts.forEach(script => {
if (['event-listener', 'user-callback'].includes(script.invokerType)) {
// We found the script that handled the user interaction
console.log(`Interaction stall: ${script.invoker} took ${script.duration}ms`);
// We can even see where the interaction happened
// This helps identify if it's a specific button or a global listener
sendToAnalytics({
type: 'input-stall',
element: script.invoker,
duration: script.duration,
frameTotal: entry.duration
});
}
});
};Integrating LoAF into Your Analytics
Collecting this data locally is great for debugging, but the real power comes from seeing it across your entire user base. Because the LoAF API is relatively lightweight, you can ship a stripped-down version of a recorder to your production environment.
The challenge is data volume. A busy site might generate hundreds of LoAF entries. You don't want to D.O.S. your own logging endpoint.
Here is a pattern for "summarized attribution" that keeps your payloads small:
let loafBuffer = [];
const flushLoafData = () => {
if (loafBuffer.length === 0) return;
const payload = JSON.stringify(loafBuffer);
// Use sendBeacon for non-blocking background transport
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/perf/loaf', payload);
}
loafBuffer = [];
};
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only log the really bad ones
if (entry.duration > 200) {
const topScript = entry.scripts.sort((a, b) => b.duration - a.duration)[0];
loafBuffer.push({
totalTime: entry.duration,
topScriptTime: topScript?.duration,
topScriptSource: topScript?.sourceLocation,
topScriptInvoker: topScript?.invoker,
timestamp: entry.startTime
});
}
}
if (loafBuffer.length > 10) flushLoafData();
}).observe({ type: 'long-animation-frame', buffered: true });
// Ensure we flush when the user leaves
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushLoafData();
});The Nuance of Script Attribution
It's important to realize that LoAF reports on *scripts*, but those scripts might be part of a larger framework. If you're a React developer, you'll notice that many of your attributions point to react-dom.production.min.js.
This might seem like you’re back at square one, but it’s actually a step forward. By looking at the invokerType, you can see if the React render was triggered by a message event (common in React's internal scheduler), a click event, or a timer.
If you see a long task attributed to react-dom triggered by a click event on NAV#main-menu, you’ve narrowed your search area from "the whole app" to "the navigation component's click handler."
Edge Cases and Gotchas
No API is perfect, and LoAF has its quirks.
1. Cross-Origin Isolation: If a script is loaded from a different origin and doesn't have the Timing-Allow-Origin header, the attribution will be muted. You'll see that a script ran, but you won't see the URL or the specific function name. This is a security measure to prevent timing attacks.
2. Breadcrumbs, Not Stack Traces: LoAF tells you the entry point of the script (the invoker). It doesn't give you a full stack trace of everything that happened during those 200ms. It’s a map of the forest, not a detailed diary of every tree.
3. Browser Support: As of now, LoAF is a Chromium-led initiative. It’s available in Chrome and Edge. While it’s not yet in Firefox or Safari, the data you get from your Chrome users is usually representative of the performance bottlenecks all your users are facing.
4. Overhead: While the API is designed to be performant, processing hundreds of script attribution objects in JavaScript *inside* a PerformanceObserver callback can itself contribute to main thread work. Always filter for the "worst offenders" to minimize the impact of your monitoring.
Why This Matters for the Future of Web Perf
For the last decade, web performance has been dominated by "Core Web Vitals." These are great high-level metrics, but they are results, not causes. LCP tells you the page loaded slowly; it doesn't tell you that your useMemo hook was poorly implemented.
The Long Animation Frame API represents a shift toward Actionable Telemetry. It bridges the gap between the "What" (the field data from users) and the "Why" (the specific lines of code in your IDE).
We are moving away from a world where we look at a "Slow" label in a dashboard and guess which Jira ticket caused it. With LoAF, we have a precise record. We can see that on Tuesday at 3:00 PM, a user on a mid-tier Android device experienced a 400ms freeze because the ValidationLogic.js script was triggered by a blur event.
That is the level of precision required to build modern, high-performance web applications. The main thread is a finite resource; it’s about time we started keeping a better ledger of how we spend it.


