DEV Community

Cover image for Your Lighthouse Score Is 100. Long Animation Frames Explain the Jank.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Your Lighthouse Score Is 100. Long Animation Frames Explain the Jank.

Lighthouse says 100. CI is green. Then a support ticket comes in: "the filter button on the dashboard feels stuck for a second." You open the page yourself, click the same filter, and feel it too — a tiny freeze between the click and the list updating. Lighthouse never saw this, because Lighthouse scores page load. Nobody clicked anything during the audit.

This is the gap between "fast" and "doesn't feel fast": a single slow response to a single interaction, days after the page finished loading. The browser has had an API for this since 2017 — Long Tasks — and it has one deep flaw that makes it almost useless for exactly this kind of ticket.

The wrong way: squinting at a flame chart for a name that isn't there

Here's the instinctive move. Open DevTools, hit record, click the filter button, stop recording, and scrub through the Performance panel looking for the red-flagged block over 50ms.

You find it. It's purple, it's wide, and when you click it, the summary says:

Task
Duration: 187.00 ms
Enter fullscreen mode Exit fullscreen mode

That's it. No function name you recognize — just "Task," sometimes attributed to a minified vendor chunk as (anonymous), sometimes split across three or four stacked calls that all blend into the same purple block. If the slow code lives in a third-party script (an analytics tag, a UI library, a chat widget), the browser won't even give you a stack — cross-origin scripts get stripped from the trace for privacy reasons, so the one task you actually care about is the one with the least information attached to it.

This is also exactly what the underlying longtask entry type gives you programmatically. It's not a DevTools limitation — the API itself reports a duration and almost nothing else:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.duration); // 187
    // ...and basically nothing else usable.
  }
}).observe({ type: "longtask", buffered: true });
Enter fullscreen mode Exit fullscreen mode

You know that something took 187ms. You still don't know what. So the next move is usually wrapping suspiciously-large chunks of code in console.time/console.timeEnd and reloading, hoping you guessed the right function on the first try. On a codebase with more than one engineer, you rarely do.

The real diagnosis: the frame, not just the task

A dropped frame isn't only JavaScript. By the time the browser paints a frame, it ran your event handler, then recalculated style, then ran layout, then painted — and any one of those steps, or the hand-off between them, can be where the 187ms actually went. longtask only ever measured the "ran your event handler" part, as one undifferentiated blob.

The Long Animation Frames API (LoAF) reports on the whole frame instead, and names what happened inside it. Where it's supported, you observe "long-animation-frame" instead of "longtask":

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("frame duration:", entry.duration);
    console.log("blocked input for:", entry.blockingDuration);

    for (const script of entry.scripts) {
      console.log({
        invoker: script.invoker,       // e.g. "BUTTON#filter-btn.onclick"
        source: script.sourceURL,
        duration: script.duration,
        forcedLayout: script.forcedStyleAndLayoutDuration,
      });
    }
  }
}).observe({ type: "long-animation-frame", buffered: true });
Enter fullscreen mode Exit fullscreen mode

Instead of one opaque "Task," each PerformanceLongAnimationFrameTiming entry hands you an array of PerformanceScriptTiming objects — one per script that ran during that frame, each with a duration, a sourceURL, and an invoker that names the actual handler ("BUTTON#filter-btn.onclick", not "anonymous"). It also exposes renderStart and styleAndLayoutStart, so you can see where the frame's time actually went: script execution, or the browser's own style/layout/paint work after your code handed off.

Run that observer against the filter-button ticket and the blob stops being a blob:

frame duration: 187
blocked input for: 134
{
  invoker: "BUTTON#filter-btn.onclick",
  source: "https://app.example.com/dashboard.js",
  duration: 162,
  forcedLayout: 71
}
Enter fullscreen mode Exit fullscreen mode

forcedStyleAndLayoutDuration: 71 is the tell. That's time spent on forced layout — reading a layout-dependent property like offsetHeight right after writing to the DOM, which makes the browser stop and recalculate synchronously instead of waiting for its normal paint schedule. The click handler wasn't slow because the filter logic was heavy; it was slow because it wrote to the DOM, then immediately read a size back from it, in a loop, once per filtered row.

The fix, once you know what you're fixing

Knowing the handler's name and that 71 of its 162ms went to forced layout turns a guessing game into a code review. The fix here is the standard one for layout thrash: stop interleaving reads and writes. Batch every DOM write first, then read sizes once, after:

// before: read-write-read-write, once per row — forces layout every iteration
rows.forEach((row) => {
  row.style.height = computeHeight(row);
  totalHeight += row.offsetHeight; // forces a synchronous layout recalculation
});

// after: write everything, then read everything, once
rows.forEach((row) => {
  row.style.height = computeHeight(row);
});
totalHeight = rows.reduce((sum, row) => sum + row.offsetHeight, 0);
Enter fullscreen mode Exit fullscreen mode

Re-run the observer and forcedStyleAndLayoutDuration on that entry drops to near zero, because there's no longer a read sandwiched between writes forcing the browser to recompute layout mid-loop. The frame still does the same amount of work — it just stops forcing the browser to do that work twice.

Why this matters beyond one button

This isn't a one-off diagnostic trick. Interaction to Next Paint (INP) — the Core Web Vital that replaced First Input Delay — measures exactly the gap between a user's interaction and the frame that responds to it. A long animation frame that overlaps a click, keypress, or tap is, by definition, the thing making your INP score worse. blockingDuration on a LoAF entry is close kin to what's slowing that metric down, which is why the Chrome team built this API specifically to make INP regressions debuggable in production, not just in a local DevTools session you happened to be recording at the right moment.

The honest caveat

The Long Animation Frames API shipped in Chrome and Edge 123 (March 2024), after an origin trial — Firefox and Safari don't implement it yet, and there's no public commitment on when or whether they will. If your users are meaningfully split across browsers, you're diagnosing the Chromium slice of your traffic, not all of it. Feature-detect before you rely on it:

if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
  // use LoAF
} else if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
  // fall back to duration-only longtask — better than nothing
}
Enter fullscreen mode Exit fullscreen mode

That's a real gap, not a footnote to skip past. But for the browser where it does exist, it turns "something took 187ms" into "this handler forced layout 71 of those milliseconds, here's the file" — which is the difference between a ticket you can act on and one you close as "couldn't reproduce."

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Next time a user says a page "just feels slow" and your Lighthouse score says otherwise, open the console and observe long-animation-frame before you touch the Performance panel. What's the slowest interaction on your own site right now — and do you already know, without looking, which handler it's going to name?

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

Official Platform Update

Security protocols have been updated for all developer accounts.

  • tr.ee/dev-to