Debugging INP Regressions With the Long Animation Frames API
INP tells you something is slow — the Long Animation Frames API tells you which script caused it. A working setup for finding the actual culprit.
Wednesday afternoon. A Core Web Vitals report lands in Slack. INP went from 180ms to 340ms after last week's deploy, and nobody knows why.
So you open Chrome DevTools, click around the page, squint at the Performance panel. Everything looks fine. You profile a button click, see some JavaScript, see some layout, see a lot of purple and yellow bars that could mean anything. The INP number is bad. The flame chart isn't talking.
It's an easy trap: hours of clicking around staging, convinced the problem is the date picker, when it's something else entirely. INP is a great metric — it captures what users actually feel when they tap a button and nothing happens. But knowing your INP is bad doesn't tell you what's causing it. The Long Animation Frames API does.
What we're building
By the end of this, you'll have a working setup that:
- Catches long animation frames in real user sessions
- Extracts the exact script, function, and character position causing the delay
- Reports that data somewhere you can search it
The output looks like this: a 198ms frame caused by handleFilterChange in /static/js/search.chunk.js at character 4021, with 142ms of script execution and 38ms of layout.
That's specific enough to open a PR.
Prerequisites
- Chrome 123+ (Long Animation Frames shipped in March 2024; Firefox and Safari still don't have it as of August 2026)
- A JavaScript application with INP problems, or a willingness to create some
- Basic familiarity with
PerformanceObserver— if you've used it for Long Tasks or Web Vitals, you're fine - Optional: JustAnalytics or any RUM tool that accepts custom events
Step 1: Understand what you're measuring
INP (Interaction to Next Paint) measures the delay between a user interaction and the next paint that reflects that interaction. Google counts any interaction over 200ms as "needs improvement" and over 500ms as "poor." If you're tracking Core Web Vitals in your analytics dashboard, you've probably already seen INP numbers — but correlating them with specific code paths requires deeper tooling.
The Long Animation Frames API (LoAF) measures something related but different. It fires whenever a rendering frame takes more than 50ms. Each entry tells you:
- Total frame duration
- How much of that was JavaScript execution
- How much was rendering (layout, paint, composite)
- Most importantly: which scripts ran and how long each took
Long Tasks only gave you a container name — "self" or "same-origin-descendant" — which was useless for debugging. I genuinely don't know what Google expected us to do with "same-origin-descendant." File a bug against our entire codebase? LoAF gives you URLs, function names, character positions. The difference is night and day.
Step 2: Set up a basic observer
Here's the minimal observer that catches long frames:
// long-animation-frames-observer.js
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Long frame:', entry.duration.toFixed(0), 'ms');
for (const script of entry.scripts) {
console.log(' Script:', script.sourceURL);
console.log(' Function:', script.sourceFunctionName || '(anonymous)');
console.log(' Char position:', script.sourceCharPosition);
console.log(' Execution:', script.executionStart, '->', script.startTime + script.duration);
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
} else {
console.warn('Long Animation Frames API not supported');
}
What to expect: a flood of entries if your page is even moderately complex. Every frame over 50ms shows up, including frames that had nothing to do with user interaction. Run it on a busy marketing page and dozens of entries can pile up within seconds. We'll filter those shortly.
The buffered: true option is important — it picks up frames that fired before the observer connected, which matters for page load.
Step 3: Filter for interaction-related frames
Most long frames are just page load jank or background work. The frames that hurt INP are the ones that happen during or immediately after a user interaction.
Here's a pattern that tags frames with the interaction that triggered them:
// interaction-aware-frames.js
let pendingInteraction = null;
document.addEventListener('pointerdown', (e) => {
pendingInteraction = {
type: 'pointerdown',
target: e.target.tagName,
timestamp: performance.now(),
};
});
document.addEventListener('keydown', (e) => {
pendingInteraction = {
type: 'keydown',
key: e.key,
timestamp: performance.now(),
};
});
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only care about frames that started within 100ms of an interaction
const timeSinceInteraction = pendingInteraction
? entry.startTime - pendingInteraction.timestamp
: Infinity;
if (timeSinceInteraction > 100) continue;
const data = {
duration: entry.duration,
renderTime: entry.renderStart ? (entry.startTime + entry.duration - entry.renderStart) : 0,
scripts: entry.scripts.map(s => ({
url: s.sourceURL,
fn: s.sourceFunctionName,
duration: s.duration,
charPos: s.sourceCharPosition,
})),
interaction: pendingInteraction,
};
console.table(data.scripts);
pendingInteraction = null;
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Now you're only seeing the frames that users felt. The output might look like:
| url | fn | duration | charPos |
|---|---|---|---|
| /static/js/main.js | handleClick | 89 | 42018 |
| /static/js/vendor.js | rerender | 67 | 1892 |
That second column is what you actually need to fix.
Step 4: Source map the character positions
The sourceCharPosition field gives you a character offset in the bundled file. Without source maps, that's just a big number. With source maps, it's a line in your actual source code.
Chrome DevTools does this automatically if you have source maps deployed. But for field data — real user sessions — you need to decode it yourself.
Here's the flow:
- Ship source maps to a private endpoint (not public, unless you want competitors reading your code)
- When a long frame comes in, grab the chunk URL and character position
- Hit your source map API to resolve it
// resolve-source-location.js
async function resolveLocation(url, charPosition) {
const mapUrl = url + '.map'; // adjust for your setup
const response = await fetch(`/api/sourcemap/resolve`, {
method: 'POST',
body: JSON.stringify({ mapUrl, charPosition }),
});
return response.json();
// returns { file: 'src/components/Search.tsx', line: 142, column: 8 }
}
Don't do this in the browser on every frame — that's way too much overhead. Batch it up, sample it, or send raw positions to your observability backend and resolve there. (If you use JustAnalytics error tracking, the @justanalyticsapp/cli package already uploads source maps from CI/CD for its source-mapped stack traces.)
Step 5: Wire it to monitoring
Logging to the console helps during development. In production you need the data somewhere searchable.
Here's a minimal reporting setup:
// report-long-frames.js
function reportFrame(frameData) {
const payload = JSON.stringify(frameData);
// navigator.sendBeacon survives page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/frames', payload);
} else {
fetch('/api/analytics/frames', {
method: 'POST',
body: payload,
keepalive: true,
});
}
}
Trying sendBeacon first matters because long frames often precede navigation — user clicks, page freezes, user gives up and leaves. A regular fetch gets cancelled on unload; sendBeacon and fetch with keepalive: true don't.
If you're using JustAnalytics, the Pro plan's Web Vitals monitoring picks up INP. For LoAF-level detail, fire a custom event:
window.JustAnalytics?.track('long_animation_frame', {
duration: frameData.duration,
script: frameData.scripts[0]?.url,
fn: frameData.scripts[0]?.fn,
});
It's recorded as a custom event with the script and function as properties, so repeat offenders show up by name.
Step 6: Debug in DevTools (the practical workflow)
Field data tells you which scripts are slow in aggregate. DevTools tells you exactly what's happening in a single interaction.
A workflow that works when INP spikes:
- Open Performance panel
- Click "Record"
- Perform the slow interaction
- Stop recording
- In the Interactions track, find your click or keypress
- Look at the Main track directly below it — tasks over 50ms are marked with a red corner
- Expand those tasks in the flame chart to see which functions ran
Keep the Step 2 observer running in the console while you record. It gives you the script attribution for the same interaction, so you can match the flame chart to the file and function.
Common errors and how to fix them
"PerformanceObserver is not defined"
You're on an older browser without PerformanceObserver, or without its static supportedEntryTypes getter. Wrap the check:
const supported = typeof PerformanceObserver !== 'undefined'
&& PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame');
Scripts array is empty on some frames
Rendering-only frames — pure layout thrash with no JavaScript — have an empty scripts array. That's correct behavior. The frame was long because of layout or paint, not script. Look at entry.renderStart and entry.styleAndLayoutStart to see where the time went.
Character positions resolve to the wrong line
Your source maps are stale. The deployed bundle doesn't match the source map you're decoding. This happens after rollbacks, or when the CDN caches an old bundle. Verify the bundle hash matches.
Frames fire for background tabs
Chrome throttles background tabs, so you'll see artificial long frames when the user switches back. Filter by document.visibilityState === 'visible' if you want to exclude those.
DevTools shows different numbers than field data
DevTools is synthetic — one machine, one run. Field data is the aggregate of real conditions: slow phones, congested networks, extensions injecting scripts. The field data is what your users experience. Trust it over your M2 MacBook. For field-level monitoring, session replay helps you see what the user was doing when INP spiked.
(Side note: if your DevTools numbers look fine and the field data doesn't, suspect extensions. A password manager or ad blocker can inject its own JavaScript on every click, and you can't simulate that locally. Extensions and bots can both tank your metrics — separating real user performance from interference is half the battle.)
Next steps
Once you can identify the slow scripts, you need to fix them. A few patterns that come up constantly:
-
Third-party scripts eating your budget. If the Long Animation Frames API points at a vendor script, you have two options: delay it (load after interaction settles) or replace it. Any script that runs synchronously on every click is a bad design in 2026, and anti-fraud and tag-manager snippets are frequent offenders.
-
Hydration storms in React/Next/Nuxt. A click that lands while the component tree is still hydrating waits behind it, spiking INP. Server components and selective hydration help.
-
Layout thrash from reading layout properties inside loops.
element.offsetHeightforces layout recalc every time. Batch reads, then batch writes. -
Heavy JSON parsing on the main thread. Web Workers exist. Use them. (I know, I know — nobody wants to set up a worker for what feels like a simple parse. But a 2MB JSON blob will absolutely tank your INP, and there's no clever trick to avoid it.) If your JSON payloads are API responses, consider trimming your backend responses first — smaller payloads parse faster.
For ongoing monitoring, set up alerts on P75 INP crossing the 200ms threshold. JustAnalytics Pro includes alerting and 1-year data retention, so you can compare regressions across deploys. Check out the APM surface if you haven't — the distributed tracing helps when what users feel after a click is a slow API response rather than client-side script, which INP won't show.
The Long Animation Frames API shipped quietly, and most INP guides still tell you to stare at the Performance panel and guess. Now you don't have to.
Frequently Asked Questions
What's the browser support for the Long Animation Frames API?
As of August 2026, LoAF is available in Chrome 123+ and Edge 123+. Firefox and Safari haven't shipped it yet. You can feature-detect with PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') and fall back to regular Long Tasks for unsupported browsers — you'll lose script attribution, but at least you'll still catch the duration.
How is Long Animation Frames different from Long Tasks?
Long Tasks tells you "something took over 50ms" and gives you a vague container name. Long Animation Frames tells you exactly which script, which function and which character position caused it — plus how long layout, style recalc, and rendering took. It's the difference between hearing a noise from your car and being told the left front wheel bearing is shot. LoAF also covers rendering work that Long Tasks misses entirely.
Should I use the web-vitals library or PerformanceObserver directly?
The web-vitals library from Google handles the attribution automatically and normalizes browser quirks, so it's the easiest path for most teams. But if you need custom logic — like correlating frames with specific user actions or filtering out bot traffic before attribution — PerformanceObserver gives you raw access. A sensible split is web-vitals for field monitoring and raw observers for deep debugging sessions.
Can I track Long Animation Frames in JustAnalytics?
Yes. JustAnalytics Pro includes Web Vitals monitoring, which covers INP. For LoAF-level detail, wire the PerformanceObserver to a custom event with the script and function as properties. The Pro plan's 1-year retention means you can compare regressions across deploys without losing history.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.