EU-based RUM solution Other devs are already building with our MCP & API

How Long Animation Frames showed us where our chat widget lost 668 milliseconds

How Long Animation Frames showed us where our chat widget lost 668 milliseconds

  • by Erwin Hofman
  • Published
  • reading time ±9 minutes
  • INP LoAF

We build tools that keep web pages fast. So it stung a little when DevTools greeted us with a row of yellow warnings on our own site, all pointing at FlowConvo, the chat widget we built ourselves:

[Violation] 'requestAnimationFrame' handler took 92ms
[Violation] Forced reflow while executing JavaScript took 91ms
[Violation] Forced reflow while executing JavaScript took 106ms
[Violation] 'click' handler took 244ms
[Violation] Forced reflow while executing JavaScript took 189ms

The good news: we did not have to guess why. One Performance trace and the Long Animation Frames API (LoAF) told us which script ran, which function took the time, and which DOM access forced the browser to do layout work early. Two of the causes surprised us. One of them was not even in our code.

This is the story of that trace, and what we changed.

The short version

  • Starting the widget took one task of 668 ms. About 420 ms of that went into creating Intl date and time formatters, mostly for variables no text on the page used
  • Hovering the chat button took 112 ms of script, of which 94 ms was forced layout. Clicking it took 249 ms, of which 190 ms was forced style and layout
  • Those layouts were slow because the host page's layout was slow: its very first layout took 479 ms for 155 layout objects, before our widget even loaded. Every layout we forced paid that price too
  • The fixes were not clever: format only what is used, share one stylesheet, split start-up into tasks, and stop reading layout in the wrong place
  • Before the widget is ready, it now creates 1Intl formatter instead of up to six
  • We shared the trace, the LoAF entries and the bundle with an AI assistant. Named functions and exact durations made its suggestions specific instead of generic

Why Long Animation Frames, and not just long tasks

A long task tells you the main thread was busy. It does not tell you with what. For a third-party script like a chat widget, that is the whole question: is it our code, the page's code, or the browser doing work on someone's behalf?

Long Animation Frames answer that. A LoAF entry covers everything between one frame and the next, and lists the scripts that ran inside it. For each script you get:

  • Where it came from: the script URL and how it was invoked, such as a classic script, a click listener or a requestAnimationFrame callback
  • How long it ran: its own duration, separate from the frame's
  • What it made the browser do: forcedStyleAndLayoutDuration, the time the browser spent on style and layout because the script asked for it too early

That last field is where the value is. "The click handler took 249 ms" is a symptom. "190 ms of it was layout we forced" is a diagnosis.

How we read it

We combined two sources. The LoAF entries, collected on the page, showed which frames were long and which scripts were in them. A DevTools Performance trace of the same page load showed the rest: the JavaScript stack behind every forced layout, and a CPU profile of where script time went.

What the trace showed

Four long frames involved our widget. Each one had a different cause.

FrameInvokerScript timeForced style and layoutCause
Start-upclassic script668 ms0 msIntl formatters, CSS parsing, building the DOM
First framerequestAnimationFrame callback93 ms92 msreading scrollY for the usage log
Hover on the chat buttonpointerenter listener112 ms106 msshowing the panel invisibly ahead of the click
Click on the chat buttonclick listener249 ms190 msopening the panel

Start-up: 420 ms of date formatting

The CPU profile of the start-up task had one clear winner: a small helper that creates and caches Intl.DateTimeFormat and Intl.RelativeTimeFormat objects. It held about 420 ms of self time.

Creating such a formatter is cheap to write and expensive to run the first time, especially with a time zone. Our widget created up to six of them at start-up: for the client's local time, for opening hours, for "closes at 17:00", for "opens tomorrow" and for "in 2 hours". On this page, the texts used exactly one of those values: whether the team is open right now. That one needs no formatting at all.

Why were they all created? Every text the widget shows went through one function that gathered all variables first, also for texts without a single $variable in them. Gathering the variables meant computing the clock, and computing the clock meant formatting every time we offer.

The rest of the task was smaller: about 40 ms to parse the 66 kB stylesheet into the widget's Shadow DOM, and about 35 ms to build the widget's DOM.

Forced layouts: whose layout is it?

The three other frames all came down to forced layout. The trace named the line each time:

  1. In the first frame, our usage log read window.scrollY inside a requestAnimationFrame callback, before the browser had done its own layout for that frame
  2. On hover, the widget showed its panel invisibly with showPopover(), to have it laid out before the click. That ran inside the pointerenter event, while the page's own :hover styles were still pending
  3. On click, the widget opened the panel and immediately touched it again, forcing the browser to lay it out in the middle of the click handler

None of these is unusual. What was unusual was the price: 85 to 140 ms for a layout of 55 to 160 objects. That is far too slow for so little.

The page itself

The explanation was at the top of the trace, before our widget had loaded: the page's very first layout took 479 ms for 155 layout objects.

That changed how we read everything else. When a script forces layout while the page has pending changes, the browser has to lay out those changes too. On a page where a layout is that expensive, every early layout costs tens of milliseconds, whoever triggers it. Our widget was not doing heavy work. It was asking for layout at moments when layout happened to be heavy.

What we changed

We shared the trace, the LoAF entries and the widget's JavaScript with an AI assistant, and asked what it would change. The combination mattered. Code alone invites generic advice. A profile that says "420 ms in this helper" and a stack that says "forced layout in this function" leave little room for that.

Format only what is used

The widget now looks once at its own configuration and collects every variable name it refers to: $names in texts, screens and forms, and names used in conditions. Times are only formatted for variables on that list. A text without a $ skips gathering variables altogether.

The client's local time and the opening hours now share one formatter, with the same locale and options. Whatever formatting is still needed later, such as "3 minutes ago" under a message, is prepared when the browser is idle, not during the first click.

Result: before the widget is ready, it creates 1 formatter instead of up to six.

One stylesheet, parsed once

The widget's CSS is now a constructed stylesheet, shared through adoptedStyleSheets. The chat and every form embedded in the page use the same parsed sheet. Before, each of them had its own Shadow DOM with its own copy of the 66 kB stylesheet. On our demo page, with a poll, a quiz and a sign-up form, that meant parsing it four times.

Start-up in separate tasks

The bundle no longer starts the widget inside its own script task. Compiling the script and starting the widget are now two tasks, scheduled with scheduler.postTask where the browser supports it. Inside the start-up, a real scheduler.yield() now separates reading the configuration from building the DOM.

That sounds like it was already there. It was, almost: the code awaited a few promises that were already resolved. That only yields to the microtask queue, not to the browser.

Stop reading layout at the wrong moment

Each forced layout got its own fix:

  1. The usage log reads the scroll position after the first paint, when the page's layout is already up to date
  2. Hover prepares the panel only after 120 ms of hovering, in a task of its own, outside the pointer event
  3. Click moves the keyboard focus into the input after the panel has been painted. Focusing an element needs an up-to-date layout; doing it inside the click forced that layout right away

Long conversations

A returning visitor can have a long conversation in the widget. Every message above the last 16 now gets content-visibility: auto. When the chat opens and scrolls to the newest message, the browser only lays out the messages near the bottom. In a test with 60 messages, 44 were skipped, and the chat still opened at the newest message.

What the trace taught us

Attribution beats totals. "The click took 249 ms" would have sent us looking at our click handler. LoAF's forcedStyleAndLayoutDuration told us the handler itself was cheap, and that the time went into layout it triggered.

A cheap call can be expensive the first time. An Intl formatter looks like a one-liner. Its first creation, with a time zone, can cost more than the rest of a start-up. Caching it is not enough if you create it for values nobody shows.

Forced layout costs what the page costs. The same DOM read can take 1 ms on one page and 100 ms on another. If you ship a script that runs on other people's pages, you cannot assume their layout is cheap. The safest layout read is the one you do after the browser has done its own.

Measure the page without your script, too. The slowest layout in the trace happened before our widget existed. Without that baseline, we would have blamed ourselves for all of it, and missed that the page itself was the biggest win.

What we still want to know

One question is open: why does that first layout of the page take 479 ms for so few objects? That is the next trace, without the widget, with layout roots in view.

From one trace to every visitor

Everything above comes from one trace of one page load, on one machine. That is how you find and fix a problem. It does not prove the fix reaches real visitors, on slower phones and busier pages.

That is why LoAF is worth collecting in the field, not only in DevTools. RUMvision collects long animation frames from every real visit, with the scripts that ran in them, so the questions a single trace cannot answer become measurable:

  • Which third-party scripts show up in long frames, and on which pages?
  • Which scripts were running when a slow interaction happened?
  • Did a release make long frames shorter for real visitors, or only on our laptop?

We will follow the widget's long frames on our own pages in RUMvision over the coming weeks, and report back.

Is your INP hiding a third-party script?

Your visitors' browsers already know which scripts make their pages slow. They report it in every long animation frame. Collect those entries, look at the forced layout times, and measure your page once without the script you suspect. You may find, like us, that the answer is half yours and half the page's. Request a demo or get started and find out which scripts your visitors are waiting for.

social share