--- title: "Use cases" canonical: "https://www.rumvision.com/use-cases/how-long-animation-frames-showed-us-where-our-chat-widget-lost-668-milliseconds/" --- Breadcrumbs: [Home](https://www.rumvision.com/?format=md) > Use cases # How Long Animation Frames showed us where our chat widget lost 668 milliseconds 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 **1** `Intl` 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. Frame Invoker Script time Forced style and layout Cause Start-up classic script 668 ms 0 ms `Intl` formatters, CSS parsing, building the DOM First frame `requestAnimationFrame` callback 93 ms 92 ms reading `scrollY` for the usage log Hover on the chat button `pointerenter` listener 112 ms 106 ms showing the panel invisibly ahead of the click Click on the chat button `click` listener 249 ms 190 ms opening 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](https://www.rumvision.com/demo/?format=md) or [get started](https://www.rumvision.com/pricing/?format=md) and find out which scripts your visitors are waiting for.