Browser Task Attribution and Long Animation Frames (LoAF) Internals
Real-World Problem Context
A performance team sees their site's Interaction to Next Paint (INP) is 350ms — well above the 200ms "good" threshold. They open Chrome DevTools and see "Long Tasks" flagged on the main thread, but the Long Tasks API only tells them a task exceeded 50ms, not which script caused it or what specifically ran during that task. Was it a React re-render? A third-party analytics script? An event handler cascading through the DOM? The Long Animation Frames (LoAF) API solves this by providing script-level attribution for slow frames: which scripts executed, their source URLs, the invoker (event handler, setTimeout, Promise), how long each script ran, and how much time was spent in forced style/layout (style recalculation). This post covers how LoAF works internally, how it attributes work to scripts, and how to use it to diagnose and fix INP issues.
Problem Statements
-
Task Attribution Gap: The Long Tasks API flags tasks >50ms but provides no breakdown of what ran inside the task — how does the Long Animation Frames API attribute time to specific scripts, their entry points, and their source locations?
-
Script-Level Diagnosis: When multiple scripts execute within a single frame (event handler → microtasks → rAF callbacks → forced layout), how does LoAF decompose the frame into individual script contributions with timing and type information?
-
INP Root Cause Analysis: How do you use LoAF data to trace an INP interaction back to the specific code path causing the delay — distinguishing input delay, processing time, and presentation delay?
Deep Dive: Internal Mechanisms
1. Long Tasks vs. Long Animation Frames
/*
* Long Tasks API (existing):
*
* PerformanceLongTaskTiming {
* name: "self"
* entryType: "longtask"
* startTime: 1234.56
* duration: 78 ← Task took 78ms (>50ms threshold)
* attribution: [{
* name: "unknown" ← Attribution is nearly useless
* containerType: ""
* containerSrc: ""
* }]
* }
*
* Problem: You know SOMETHING took 78ms but not WHAT.
*
*
* Long Animation Frames API (LoAF):
*
* PerformanceLongAnimationFrameTiming {
* entryType: "long-animation-frame"
* startTime: 1234.56
* duration: 120
* renderStart: 1300.00 ← When rendering began
* styleAndLayoutStart: 1310.00 ← When style/layout began
* firstUIEventTimestamp: 1230.00 ← When user interaction happened
* blockingDuration: 80 ← Time exceeding 50ms threshold
* scripts: [ ← DETAILED script attribution!
* {
* name: "script"
* invoker: "BUTTON#submit.onclick"
* invokerType: "event-listener"
* sourceURL: "https://myapp.com/app.js"
* sourceFunctionName: "handleSubmit"
* sourceCharPosition: 4523
* startTime: 1234.56
* executionStart: 1235.00
* duration: 45
* forcedStyleAndLayoutDuration: 12
* pauseDuration: 0
* },
* {
* invoker: "IMG#hero.onload"
* invokerType: "event-listener"
* sourceURL: "https://analytics.third-party.com/tracker.js"
* sourceFunctionName: "onImageLoad"
* duration: 35
* forcedStyleAndLayoutDuration: 20
* }
* ]
* }
*
* LoAF answers: WHICH script, WHAT function, HOW LONG,
* and HOW MUCH forced layout.
*/
2. LoAF Timing Model
/*
* A long animation frame spans from task start to render end:
*
* ┌──────────── Long Animation Frame ────────────────────┐
* │ │
* │ ┌─── Script 1 ───┐ ┌── Script 2 ──┐ │
* │ │ Event handler │ │ setTimeout │ ┌──Render──┐ │
* │ │ + microtasks │ │ callback │ │Style │ │
* │ │ │ │ │ │Layout │ │
* │ │ │ │ │ │Paint │ │
* │ └─────────────────┘ └──────────────┘ └──────────┘ │
* │ │
* ├───────────────────────┬──────────────┬────────────────┤
* startTime renderStart styleAndLayout end
* Start
*
* Key timestamps:
* startTime: Frame begins (first task starts)
* renderStart: Rendering pipeline begins
* styleAndLayoutStart: Style recalc + layout begins
* duration: Total frame time
* blockingDuration: Time beyond 50ms threshold
*
* For INP specifically:
* firstUIEventTimestamp: When the user actually pressed/clicked
*
* INP breakdown:
* Input Delay = startTime - firstUIEventTimestamp
* Processing = renderStart - startTime
* Presentation = (startTime + duration) - renderStart
*/
3. Observing Long Animation Frames
// Basic LoAF observation:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Long Animation Frame:', {
duration: entry.duration,
blockingDuration: entry.blockingDuration,
renderStart: entry.renderStart,
styleAndLayoutStart: entry.styleAndLayoutStart,
firstUIEventTimestamp: entry.firstUIEventTimestamp,
scriptCount: entry.scripts.length,
});
// Examine each script in the frame:
for (const script of entry.scripts) {
console.log(' Script:', {
invoker: script.invoker,
invokerType: script.invokerType,
sourceURL: script.sourceURL,
sourceFunctionName: script.sourceFunctionName,
duration: script.duration,
executionStart: script.executionStart,
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration,
pauseDuration: script.pauseDuration,
});
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
/*
* Script entry properties:
*
* invokerType:
* "classic-script" → <script> tag execution
* "module-script" → <script type="module"> execution
* "event-listener" → DOM event handler
* "user-callback" → setTimeout, setInterval, rAF
* "resolve-promise" → Promise resolution/then callback
* "reject-promise" → Promise rejection/catch callback
*
* invoker (examples):
* "BUTTON#submit.onclick" → click handler on button
* "Window.setTimeout" → setTimeout callback
* "Window.requestAnimationFrame" → rAF callback
* "Response.json.then" → fetch response processing
* "https://example.com/app.js" → script evaluation
*
* sourceURL: The script file that contained the function
* sourceFunctionName: The function name (if available)
* sourceCharPosition: Character offset in source file
*/
4. INP Attribution with LoAF
/*
* The killer use case: diagnosing INP issues.
* INP = worst interaction responsiveness across page lifecycle.
*
* LoAF gives exact attribution for what made the interaction slow.
*/
// Capture LoAF entries that correlate with interactions:
const interactionMap = new Map();
// Track interactions via Event Timing API:
const eventObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.interactionId) {
const existing = interactionMap.get(entry.interactionId) || {
latency: 0,
entries: [],
};
existing.latency = Math.max(existing.latency, entry.duration);
existing.entries.push(entry);
interactionMap.set(entry.interactionId, existing);
}
}
});
eventObserver.observe({ type: 'event', buffered: true, durationThreshold: 16 });
// Correlate LoAF with interactions:
const loafObserver = new PerformanceObserver((list) => {
for (const loaf of list.getEntries()) {
if (!loaf.firstUIEventTimestamp) continue; // Not interaction-driven
// Calculate INP breakdown:
const inputDelay = loaf.startTime - loaf.firstUIEventTimestamp;
const processingTime = loaf.renderStart - loaf.startTime;
const presentationDelay = (loaf.startTime + loaf.duration) - loaf.renderStart;
console.log('INP Breakdown:', {
totalDuration: loaf.duration,
inputDelay: Math.round(inputDelay),
processingTime: Math.round(processingTime),
presentationDelay: Math.round(presentationDelay),
});
// Find the slowest script (likely culprit):
const sortedScripts = [...loaf.scripts]
.sort((a, b) => b.duration - a.duration);
if (sortedScripts.length > 0) {
const culprit = sortedScripts[0];
console.log('Primary culprit:', {
invoker: culprit.invoker,
sourceURL: culprit.sourceURL,
function: culprit.sourceFunctionName,
duration: culprit.duration,
forcedLayout: culprit.forcedStyleAndLayoutDuration,
});
}
}
});
loafObserver.observe({ type: 'long-animation-frame', buffered: true });
5. Third-Party Script Attribution
/*
* LoAF reveals which third-party scripts cause performance issues:
*
* Frame duration: 180ms
* ├── your-app.js: 45ms (handleClick)
* ├── analytics.js: 60ms (trackEvent) ← Third-party!
* ├── tag-manager.js: 40ms (processTag) ← Third-party!
* └── render pipeline: 35ms
*
* Now you can prove to stakeholders that the analytics script
* is responsible for 33% of the frame time.
*/
function categorizeScripts(loafEntry) {
const firstParty = [];
const thirdParty = [];
const unknown = [];
const ownOrigin = location.origin;
for (const script of loafEntry.scripts) {
if (!script.sourceURL) {
unknown.push(script);
} else if (script.sourceURL.startsWith(ownOrigin)) {
firstParty.push(script);
} else {
thirdParty.push(script);
}
}
return {
firstParty: {
scripts: firstParty,
totalDuration: firstParty.reduce((sum, s) => sum + s.duration, 0),
},
thirdParty: {
scripts: thirdParty,
totalDuration: thirdParty.reduce((sum, s) => sum + s.duration, 0),
byOrigin: groupByOrigin(thirdParty),
},
unknown: {
scripts: unknown,
totalDuration: unknown.reduce((sum, s) => sum + s.duration, 0),
},
};
}
function groupByOrigin(scripts) {
const groups = {};
for (const script of scripts) {
try {
const origin = new URL(script.sourceURL).origin;
if (!groups[origin]) {
groups[origin] = { scripts: [], totalDuration: 0 };
}
groups[origin].scripts.push(script);
groups[origin].totalDuration += script.duration;
} catch {
// Invalid URL
}
}
return groups;
}
// Report to analytics:
function reportThirdPartyImpact(loafEntries) {
const impact = {};
for (const entry of loafEntries) {
const categorized = categorizeScripts(entry);
for (const [origin, data] of Object.entries(categorized.thirdParty.byOrigin)) {
if (!impact[origin]) {
impact[origin] = { totalTime: 0, frameCount: 0 };
}
impact[origin].totalTime += data.totalDuration;
impact[origin].frameCount++;
}
}
// Sort by total impact:
return Object.entries(impact)
.sort(([, a], [, b]) => b.totalTime - a.totalTime)
.map(([origin, data]) => ({
origin,
totalBlockingTime: Math.round(data.totalTime),
framesAffected: data.frameCount,
avgPerFrame: Math.round(data.totalTime / data.frameCount),
}));
}
6. Forced Style and Layout Detection
/*
* forcedStyleAndLayoutDuration: time spent in synchronous
* style recalculation and layout forced by JavaScript.
*
* "Layout thrashing" = reading layout properties after DOM writes
*
* BAD (forces layout per iteration):
* for (const el of elements) {
* el.style.width = el.offsetWidth + 10 + 'px';
* ▲ write ▲ read (forces layout)
* }
*
* LoAF exposes this per-script so you can pinpoint which
* script is thrashing layout.
*/
// Detect layout thrashing via LoAF:
const layoutThrashObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
for (const script of entry.scripts) {
const ratio = script.forcedStyleAndLayoutDuration / script.duration;
if (script.forcedStyleAndLayoutDuration > 10 && ratio > 0.3) {
console.warn('Layout thrashing detected:', {
invoker: script.invoker,
source: script.sourceURL,
function: script.sourceFunctionName,
totalDuration: script.duration,
forcedLayoutTime: script.forcedStyleAndLayoutDuration,
percentForced: Math.round(ratio * 100) + '%',
});
}
}
}
});
layoutThrashObserver.observe({ type: 'long-animation-frame' });
/*
* Properties that trigger forced layout/reflow:
*
* Element geometry:
* offsetTop, offsetLeft, offsetWidth, offsetHeight
* clientTop, clientLeft, clientWidth, clientHeight
* scrollTop, scrollLeft, scrollWidth, scrollHeight
* getBoundingClientRect()
* getComputedStyle()
*
* Fix: Batch reads, then batch writes
*
* LoAF tells you:
* "script X spent 45ms, of which 30ms was forced layout"
* → Focus optimization efforts here
*/
7. Performance Monitoring with LoAF
// Production RUM (Real User Monitoring) integration:
class LoAFMonitor {
constructor(options = {}) {
this.threshold = options.blockingThreshold || 100;
this.sampleRate = options.sampleRate || 0.1; // 10% sampling
this.buffer = [];
this.maxBufferSize = options.maxBufferSize || 50;
}
start() {
if (!('PerformanceLongAnimationFrameTiming' in globalThis)) {
console.warn('LoAF API not supported');
return;
}
// Sample to reduce data volume:
if (Math.random() > this.sampleRate) return;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.blockingDuration < this.threshold) continue;
this.buffer.push(this._summarize(entry));
if (this.buffer.length >= this.maxBufferSize) {
this._flush();
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
// Flush on page hide:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this._flush();
}
});
}
_summarize(entry) {
return {
timestamp: Date.now(),
url: location.href,
duration: Math.round(entry.duration),
blockingDuration: Math.round(entry.blockingDuration),
hadInteraction: !!entry.firstUIEventTimestamp,
inputDelay: entry.firstUIEventTimestamp
? Math.round(entry.startTime - entry.firstUIEventTimestamp)
: null,
scriptCount: entry.scripts.length,
// Top 3 scripts by duration:
topScripts: [...entry.scripts]
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map(s => ({
invoker: s.invoker,
invokerType: s.invokerType,
sourceURL: s.sourceURL,
function: s.sourceFunctionName,
duration: Math.round(s.duration),
forcedLayout: Math.round(s.forcedStyleAndLayoutDuration),
})),
};
}
_flush() {
if (this.buffer.length === 0) return;
const payload = this.buffer.splice(0);
// Use sendBeacon for reliability:
navigator.sendBeacon(
'/api/rum/loaf',
JSON.stringify(payload)
);
}
}
// Usage:
const monitor = new LoAFMonitor({
blockingThreshold: 100,
sampleRate: 0.1,
});
monitor.start();
8. LoAF vs. Other Performance APIs
/*
* Comparison of browser performance timing APIs:
*
* ┌──────────────────────┬────────────┬──────────────┬───────────┐
* │ API │ Granularity│ Attribution │ Use Case │
* ├──────────────────────┼────────────┼──────────────┼───────────┤
* │ Long Tasks │ Task │ None │ Basic │
* │ │ (>50ms) │ (container) │ detection │
* │ │ │ │ │
* │ Event Timing │ Event │ Event type + │ INP │
* │ │ │ target │ metric │
* │ │ │ │ │
* │ Long Animation │ Frame │ Scripts + │ Root │
* │ Frames (LoAF) │ (>50ms) │ source URLs │ cause │
* │ │ │ + functions │ analysis │
* │ │ │ + invokers │ │
* │ │ │ │ │
* │ Element Timing │ Element │ Specific │ LCP │
* │ │ │ DOM element │ analysis │
* │ │ │ │ │
* │ Layout Instability │ Shift │ Shifted │ CLS │
* │ │ │ elements │ analysis │
* └──────────────────────┴────────────┴──────────────┴───────────┘
*
* LoAF is the most detailed for JavaScript attribution.
* It complements Event Timing (which gives INP scores)
* by explaining WHY an interaction was slow.
*/
9. Debugging Workflow with LoAF
/*
* Step-by-step debugging workflow for slow interactions:
*
* 1. Identify: INP > 200ms (via CrUX or RUM)
* 2. Capture: LoAF entries with firstUIEventTimestamp
* 3. Decompose: Input delay vs processing vs presentation
* 4. Attribute: Which scripts ran, how long each took
* 5. Diagnose: Forced layout? Third-party? Heavy computation?
* 6. Fix: Defer, debounce, offload to worker, or optimize
* 7. Verify: INP improved in field data
*/
// Diagnostic utility:
function diagnoseINP(loafEntry) {
const diagnosis = {
totalDuration: loafEntry.duration,
phases: {},
recommendations: [],
};
// Phase breakdown:
if (loafEntry.firstUIEventTimestamp) {
const inputDelay = loafEntry.startTime - loafEntry.firstUIEventTimestamp;
const processing = loafEntry.renderStart - loafEntry.startTime;
const presentation = (loafEntry.startTime + loafEntry.duration) - loafEntry.renderStart;
diagnosis.phases = { inputDelay, processing, presentation };
// Identify the bottleneck phase:
const maxPhase = Object.entries(diagnosis.phases)
.sort(([, a], [, b]) => b - a)[0];
diagnosis.bottleneck = maxPhase[0];
// Phase-specific recommendations:
if (maxPhase[0] === 'inputDelay' && maxPhase[1] > 100) {
diagnosis.recommendations.push(
'High input delay: main thread was busy when user interacted.',
'Look for long tasks running before the interaction.',
'Consider using `isInputPending()` to yield to pending input.',
'Break up long-running initialization or data processing.'
);
}
if (maxPhase[0] === 'processing' && maxPhase[1] > 100) {
diagnosis.recommendations.push(
'High processing time: event handler is slow.',
'Profile the specific handler identified in scripts[].',
'Consider deferring non-essential work with `requestIdleCallback`.',
'Move heavy computation to a Web Worker.'
);
}
if (maxPhase[0] === 'presentation' && maxPhase[1] > 100) {
diagnosis.recommendations.push(
'High presentation delay: rendering is slow.',
'Check for forced style/layout in scripts.',
'Reduce DOM size or complexity of the update.',
'Consider `content-visibility: auto` for off-screen content.'
);
}
}
// Analyze scripts:
for (const script of loafEntry.scripts) {
if (script.forcedStyleAndLayoutDuration > 10) {
diagnosis.recommendations.push(
`Layout thrashing in ${script.sourceFunctionName || script.invoker}: ` +
`${Math.round(script.forcedStyleAndLayoutDuration)}ms forced layout. ` +
`Batch DOM reads before writes.`
);
}
}
return diagnosis;
}
10. Browser Support and Polyfill Strategy
/*
* LoAF API availability (as of 2024):
* Chrome 123+: Full support
* Edge 123+: Full support (Chromium-based)
* Firefox: Not supported
* Safari: Not supported
*
* Since LoAF is observational (read-only), no polyfill is possible.
* Use feature detection and fall back to Long Tasks API.
*/
function observeFramePerformance(callback) {
// Prefer LoAF if available:
if ('PerformanceLongAnimationFrameTiming' in globalThis) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
callback({
type: 'loaf',
duration: entry.duration,
blockingDuration: entry.blockingDuration,
scripts: entry.scripts.map(s => ({
invoker: s.invoker,
invokerType: s.invokerType,
sourceURL: s.sourceURL,
sourceFunctionName: s.sourceFunctionName,
duration: s.duration,
forcedLayout: s.forcedStyleAndLayoutDuration,
})),
hadInteraction: !!entry.firstUIEventTimestamp,
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
return observer;
}
// Fallback to Long Tasks:
if ('PerformanceLongTaskTiming' in globalThis) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
callback({
type: 'longtask',
duration: entry.duration,
blockingDuration: entry.duration - 50,
scripts: [], // No attribution available
hadInteraction: false, // Can't determine
});
}
});
observer.observe({ type: 'longtask', buffered: true });
return observer;
}
console.warn('Neither LoAF nor Long Tasks API available');
return null;
}
/*
* Chrome DevTools integration:
*
* Performance tab → "Long Animation Frames" track shows:
* - Frame boundaries
* - Script blocks within each frame
* - Forced recalc/layout indicators
* - Source links to the responsible code
*
* Console API:
* performance.getEntriesByType('long-animation-frame')
* → Returns buffered LoAF entries for inspection
*/
Trade-offs & Considerations
| Aspect | Long Tasks API | LoAF API | Event Timing API |
|---|---|---|---|
| Measures | Task duration | Frame duration + scripts | Event processing time |
| Attribution | None (container only) | Script URL + function + invoker | Event type + target |
| Forced layout info | No | Yes (per-script) | No |
| INP correlation | Indirect | Direct (firstUIEventTimestamp) | Direct (interactionId) |
| Browser support | Wide (Chrome 58+) | Chrome 123+ only | Chrome 96+ |
| Overhead | Minimal | Low | Minimal |
| Data volume | Low | Medium (script entries) | Low |
Best Practices
-
Combine LoAF with Event Timing for complete INP diagnosis — Event Timing gives you the interaction's total latency and identifies which interaction is the INP candidate; LoAF gives you the script-level breakdown of why that frame was slow; correlate them using
firstUIEventTimestampto map LoAF entries to specific interactions. -
Sample LoAF data in production RUM to manage data volume — each LoAF entry can contain multiple script entries with URLs and function names; at scale, this generates significant data; sample at 1-10% of sessions, focus on entries with
blockingDuration > 100ms, and send only the top 3 scripts per entry to your analytics endpoint. -
Use
forcedStyleAndLayoutDurationto identify layout thrashing — when this value is a significant portion of a script's total duration, the script is reading layout properties after DOM mutations; the fix is batching DOM reads before writes, or usingrequestAnimationFrameto defer writes. -
Attribute third-party script impact using
sourceURLgrouping — group LoAF script entries by origin to quantify how much frame time third-party scripts consume; present this data to stakeholders as evidence when advocating for removing or deferring analytics, ad, or chat widget scripts. -
Feature-detect LoAF and fall back gracefully to Long Tasks — LoAF is Chromium-only; wrap observation in
'PerformanceLongAnimationFrameTiming' in globalThischecks and fall back to the Long Tasks API on other browsers; the Long Tasks fallback gives duration but no attribution.
Conclusion
The Long Animation Frames (LoAF) API provides the missing piece in browser performance debugging: script-level attribution for slow frames. Where the Long Tasks API only flags "a task exceeded 50ms," LoAF breaks down each frame into individual script contributions — revealing which script ran (sourceURL), what function executed (sourceFunctionName), how it was invoked (invokerType: event listener, setTimeout, promise resolution), how long it ran (duration), and how much time was spent in forced style/layout recalculation (forcedStyleAndLayoutDuration). For INP diagnosis, LoAF decomposes the interaction into input delay (main thread busy before handler runs), processing time (event handler + microtasks), and presentation delay (rendering pipeline). The firstUIEventTimestamp correlates LoAF entries with user interactions. In production, sample LoAF data at low rates, capture the top scripts per slow frame, and group by origin to attribute third-party impact. Combined with Event Timing for INP scores and Chrome DevTools for local profiling, LoAF completes the observability picture for frontend interaction performance.
GIF via GIPHYWhat did you think?