Engineering Diaries · Performance
§
PerformanceBrowser APIsObservability

Long Tasks Are Not Why Your Page Feels Slow. Chrome Shipped An API That Finally Tells You What Is.

The Long Tasks API tells you that something blocked the main thread for 340ms. It will not tell you which script, which handler, or which line. Long Animation Frames does - and it measures the unit users actually perceive, which is the frame, not the task.

01 · The Symptom

The Dashboard Said We Were Fine

There is a specific kind of frustration that comes from a performance dashboard which is simultaneously green and wrong. Our p75 numbers were inside budget. The synthetic runs in CI passed. And yet the support queue kept collecting the same sentence, phrased a dozen different ways: the page freezes for a second when I click things.

I have spent the last few years on frontend infrastructure, which mostly means I own the tools that are supposed to answer exactly this question. So I did the obvious thing. I opened the Long Tasks data we had been collecting from real users for months, and I looked for the freeze.

It was there. Plenty of entries, comfortably over the threshold, clustered right around the interactions people were complaining about. The API had faithfully recorded that something on the main thread ran for 340 milliseconds.

And that was the entire contents of the message. Not which script. Not which event handler. Not which of the roughly two hundred functions that could plausibly have been on the stack. Just a duration, a start time, and the digital equivalent of a shrug.

Long Tasks is a smoke alarm that tells you the building is on fire but refuses, on principle, to tell you which room.

What follows is what I learned digging into why that API is so unhelpful, why its core assumption is subtly wrong, and what replaced it. If you have ever stared at a longtask entry and wondered what you were supposed to do with it, this is for you.

02 · The Old Tool

What Long Tasks Actually Measures

The Long Tasks API landed in 2017 and it does exactly one thing: it reports when a single task on the main thread runs for longer than 50 milliseconds. You observe it like any other performance entry.

longtasks.js
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.name, entry.duration, entry.attribution);
  }
});

observer.observe({ type: 'longtask', buffered: true });

The first time I ran this I was genuinely excited about that third property. attribution sounds like it is going to tell you who is responsible. It is right there in the name.

Here is what it actually contains. The attribution array holds TaskAttributionTiming objects, and those objects describe the container the task ran in - the document, an iframe, an embed. Not the script. The container. So for the overwhelmingly common case where your slow code is running in your own top-level page, every single entry reports the same thing:

console output
// name: "self", containerType: "window", containerSrc: "", containerId: ""
self  341.5  [TaskAttributionTiming]

That is the ceiling of what the API can tell you. It is genuinely useful if your performance problem is a third-party iframe, because then containerSrc names the culprit and you are done. For everything else - which is to say, for almost every real performance investigation I have ever run - it is a dead end.

People built elaborate workarounds. Wrapping event handlers in manual performance.mark() calls. Monkey-patching addEventListener to time every callback. Correlating long task timestamps against a separately-collected custom trace and hoping the clocks lined up. I have written versions of all three. They are all fragile, they all add overhead, and they all miss the code you did not think to instrument, which is invariably the code that turns out to be slow.

Here is the shape of the second one, so you can appreciate why nobody enjoyed maintaining it:

the-old-way.js
const original = EventTarget.prototype.addEventListener;

EventTarget.prototype.addEventListener = function (type, handler, opts) {
  const wrapped = function (...args) {
    const t0 = performance.now();
    try { return handler.apply(this, args); }
    finally { record(type, performance.now() - t0); }
  };
  return original.call(this, type, wrapped, opts);
};

It works, sort of. It also breaks removeEventListener unless you maintain a map from every original handler to its wrapper, misses anything registered before your patch ran, adds a closure to every listener on the page, and tells you nothing at all about promise continuations or animation frame callbacks, because those never go through addEventListener. Every team I know that tried this eventually deleted it, usually after it caused a bug considerably more expensive than the one it was meant to help diagnose.

03 · The Deeper Problem

Fifty Milliseconds Of The Wrong Thing

The missing attribution is the complaint everyone has. But there is a second problem with Long Tasks that is more interesting, and I think less widely understood, because it is not a gap in the data. It is a flaw in the unit.

Long Tasks measures tasks. Users do not perceive tasks. Users perceive frames.

Consider a click handler that does its work in six separate chunks. Maybe it is a React update that yields between units, maybe it is a few promise continuations, maybe it is a handler that schedules follow-up work in requestAnimationFrame. Each chunk runs for 30 milliseconds. None of them crosses the 50 millisecond line. Long Tasks reports nothing at all.

Meanwhile the browser could not paint for 180 milliseconds, because the six chunks all landed before the same rendering opportunity. The user clicked, the interface sat there, and your monitoring is a clean sheet.

The core mismatch

A task is a unit of work the scheduler cares about. A frame is a unit of responsiveness the user cares about. Long Tasks measures the first and quietly hopes it is a good proxy for the second. Often it is. When your code yields, it is not.

This gets worse the more modern your stack is, which is the part I find genuinely funny. Concurrent rendering, time slicing, scheduler yield points - every technique we adopted specifically to break up long tasks also breaks up the reporting of long tasks. The framework chops a 200 millisecond block into sub-threshold pieces. The jank does not go away, because the frame still cannot paint. Only the telemetry improves.

I want to be precise here rather than sensational, because this is the kind of claim that deserves pushback: yielding genuinely does help responsiveness a lot of the time, since it creates openings for the browser to handle input between chunks. The point is narrower. Yielding reliably reduces your long task count, and it only sometimes reduces user-perceived jank. Those two things drifting apart is exactly the situation where your dashboard goes green while your users do not.

There is also the render phase itself. Style recalculation, layout, paint and compositing all happen after your JavaScript hands control back. Long Tasks does not include that work in its entries. So a handler that sets one class name and triggers an enormous layout looks cheap by task duration and feels terrible in practice.

04 · The Replacement

Long Animation Frames Changes The Unit

The Long Animation Frames API - LoAF, and yes everyone pronounces it "loaf" - shipped in Chrome 123. It reframes the measurement around the thing that actually matters. Instead of asking "did any single task run long?", it asks "did any rendering update take too long to complete?"

A long animation frame covers everything from the moment the browser starts running work for a frame, through all the scripts that execute, through style and layout, to the point where rendering finishes. If that whole span exceeds 50 milliseconds, you get an entry. Those six 30 millisecond chunks from the previous section? One LoAF entry, correctly reporting the full span the user sat through.

It is worth being precise about the name, because "animation frame" makes it sound like this only applies during animations. It does not. The API covers any rendering update the browser performs, which very much includes the frame produced after a click handler mutates the DOM. If the main thread was busy in the run-up to a rendering opportunity, that is a candidate frame, whether or not anything on screen was moving.

2017
Long Tasks shipped
123
Chrome version for LoAF
50ms
Threshold for both
9
Fields per script entry

The entry shape is where it gets good. A PerformanceLongAnimationFrameTiming gives you the usual startTime and duration, and then several fields that break the frame into phases:

  • renderStart - when the browser stopped running your scripts and started the rendering work for this frame.
  • styleAndLayoutStart - when style recalculation and layout began, which lets you separate "my JavaScript was slow" from "my JavaScript caused an expensive layout".
  • blockingDuration - how much of the frame genuinely blocked the main thread from responding to input.
  • firstUIEventTimestamp - when the first user interaction arrived during this frame, which is how you connect a slow frame back to the click that was waiting on it.
  • scripts - an array, and this is the one that changes everything.
05 · The Payoff

The scripts Array Is The Whole Point

Every entry in scripts is a PerformanceScriptTiming, and it answers the question Long Tasks spent seven years refusing to answer.

loaf-observer.js
const observer = new PerformanceObserver((list) => {
  for (const frame of list.getEntries()) {
    for (const script of frame.scripts) {
      console.log({
        invoker: script.invoker,
        invokerType: script.invokerType,
        sourceURL: script.sourceURL,
        fn: script.sourceFunctionName,
        charPos: script.sourceCharPosition,
        forcedLayout: script.forcedStyleAndLayoutDuration,
      });
    }
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

Run that on a page with a genuinely slow handler and you get something you can act on immediately - a function name, a file, and an offset into that file.

console output
{
  invoker: 'BUTTON#apply-filters.onclick',
  invokerType: 'event-listener',
  sourceURL: 'https://example.com/assets/filters.9f2a1c.js',
  fn: 'recomputeVisibleRows',
  charPos: 18422,
  forcedLayout: 83.5
}

Compare that against self 341.5 from section two. Same page, same slow interaction, same 50 millisecond threshold, two completely different situations to be in on a Friday afternoon. One of them ends the investigation. The other one starts it.

Two of those fields deserve special attention.

invokerType tells you how the browser came to run this code, and the values are more useful than they first appear: event-listener, user-callback, resolve-promise, reject-promise, classic-script, module-script. The distinction between an event listener and a promise continuation is exactly the distinction that used to require monkey-patching to recover. A frame dominated by resolve-promise entries is telling you that your slowness is in async continuations piling up, which is a completely different fix from a slow click handler.

The field I did not expect to care about

forcedStyleAndLayoutDuration reports how long this specific script spent inside forced synchronous layout. Layout thrashing has always been the classic frontend performance bug, and it has always been miserable to attribute in production. Now it is a number on a per-script object.

That last one genuinely changed how I triage. Layout thrashing - reading offsetHeight in a loop that also writes styles - is easy to demonstrate in a blog post and startlingly hard to find in a large application, because the read and the write are usually in different modules written by different people two years apart. A field that says "this function burned 80 milliseconds forcing layout" collapses a day of bisecting into a single lookup.

06 · Reading The Data

Anatomy Of A Slow Frame

The phase timestamps only click once you lay them out in order. A frame runs through these stages, and the gaps between the markers are what you actually diagnose.

One long animation frame, start to finish
startTime
scripts run
renderStart
styleAndLayoutStart
frame painted

Which turns into a diagnosis table. Given an entry, the interesting quantities are differences, not the raw numbers:

MeasureHow to compute itWhat a large value means
Script timerenderStart - startTimeYour JavaScript is the bottleneck. Go read the scripts array.
Pre-layout renderstyleAndLayoutStart - renderStartTime in rAF callbacks and ResizeObserver handlers that run at render time.
Style and layoutstartTime + duration - styleAndLayoutStartExpensive selectors, or a DOM change that invalidated far more than you expected.
Input delayfirstUIEventTimestamp - startTimeHow long a real interaction sat waiting for this frame to finish.

The first row versus the third row is the split that matters most in practice, and it is the one Long Tasks could never give you. "The frame took 200 milliseconds" is not actionable. "The frame took 200 milliseconds and 150 of them were style and layout, after a script that touched one class name on a container" is a bug report with a fix attached.

One caveat on arithmetic. Treat these as a timeline you inspect, not as quantities that must sum perfectly. Frames have phases that overlap and edge cases at the boundaries, and if you build alerting that asserts the parts add up exactly to duration, you will be chasing phantom discrepancies. Use the differences to point at a suspect, then confirm in a trace.

07 · The Subtle Field

blockingDuration Is Not duration

This one trips people up, and I got it wrong myself at first, so it is worth being explicit.

duration is how long the frame took. blockingDuration is how much of that time genuinely prevented the browser from responding to input. They are different numbers and the second is usually much smaller.

The reason is the same reasoning behind Total Blocking Time. A task that runs for 60 milliseconds is not 60 milliseconds of unresponsiveness, because the browser can pick up input between tasks, and short tasks are considered acceptable. So the accounting forgives the first 50 milliseconds of each long task and counts only the excess. A single 60 millisecond task contributes 10 milliseconds of blocking. A 300 millisecond task contributes 250.

Put numbers on it. Say a frame runs 220 milliseconds in total: a 70 millisecond script, a 40 millisecond script, then 110 milliseconds of style and layout. The 40 millisecond piece is under the bar and forgiven outright. The 70 millisecond piece contributes 20. Sort your dashboard by duration and this frame looks like an emergency. Sort by blockingDuration and it drops down the list - correctly - below a frame that blocked solidly for 250 milliseconds and made somebody give up on a form.

Do not alert on duration

A frame with a large duration but a near-zero blockingDuration is often a frame that did a lot of work politely, in chunks, with room for input in between. Alerting on duration will bury you in entries that no user ever felt. blockingDuration is the field that correlates with the complaint in your support queue.

This is also the bridge to Interaction to Next Paint. INP tells you that an interaction was slow and gives you a number. It does not tell you why. LoAF tells you which frame was slow and which script inside it was responsible. Used together you get a complete story: INP identifies the bad interaction, and the LoAF entry overlapping it names the function.

If you use the web-vitals library, this is already wired up for you - the attribution build uses LoAF under the hood to enrich INP reports. That is the path I would recommend for most teams over hand-rolling an observer, because the library has already handled the timing correlation edge cases that are genuinely fiddly to get right.

08 · Production

Wiring It Into Real Monitoring

Collecting LoAF from real users needs a bit more care than the console example, mostly because the raw data is verbose and the interesting entries are rare.

The single most important option is durationThreshold. The default is 50 milliseconds, which on a busy page produces a firehose. Raise it.

rum/loaf.ts
const observer = new PerformanceObserver((list) => {
  for (const frame of list.getEntries()) {
    // Only the frames a user could actually feel.
    if (frame.blockingDuration < 50) continue;

    // The worst single script explains most frames. Send that one.
    const worst = frame.scripts
      .slice()
      .sort((a, b) => b.duration - a.duration)[0];

    report({
      blocking: Math.round(frame.blockingDuration),
      script: frame.duration - (frame.renderStart - frame.startTime),
      fn: worst?.sourceFunctionName ?? 'unknown',
      src: worst?.sourceURL ?? '',
      kind: worst?.invokerType ?? '',
    });
  }
});

observer.observe({
  type: 'long-animation-frame',
  buffered: true,
  durationThreshold: 150,
});

A few things I would do differently from the naive version:

  • Filter on blockingDuration, not duration, for the reason in the previous section.
  • Do not ship the whole scripts array. On a heavy frame it can hold dozens of entries and your payload balloons. The slowest one explains most of the frame.
  • Sample. You do not need this from every session. A small percentage of users produces enough signal, and the whole point is to find systemic problems, not individual ones.
  • Watch your cardinality. sourceFunctionName plus sourceURL is a nice tidy key. sourceCharPosition is not - it changes on every deploy, and if you make it part of a metric label your observability bill will teach you a lesson.
  • Feature-detect. PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') before you construct anything.
09 · Honest Limits

What LoAF Will Not Do For You

I have been enthusiastic for seven sections, so here is the other side. These are the things I wish someone had told me before I started building on it.

It is a Chromium feature. At the time of writing it is not available in Safari or Firefox. Check the current support tables before you read anything here as settled, but plan on this being true. The practical consequence is that LoAF is a diagnostic tool, not a universal metric. You cannot make it the number on the executive dashboard, because it will be silent for a meaningful share of your users and that silence is not the same as speed.

Cross-origin scripts go dark. If a script comes from a different origin and the response does not carry a Timing-Allow-Origin header, you do not get its URL or function name. Which means the third-party tag manager that is actually eating your main thread shows up as an anonymous blob. There is a real irony in the attribution API losing attribution for exactly the code you have least control over.

Every document reports separately. An observer sees the long frames of the document it was created in. Work inside an iframe is reported to an observer inside that iframe, not to yours. So if your page embeds third-party widgets in frames, you would need a collector running in each one, which is generally not possible for code you do not own. This is the one place where old Long Tasks attribution is still ahead: containerSrc at least named the frame.

Your function names are only as good as your build. sourceFunctionName reports what is in the shipped bundle. If that is minified, you get t. You need source maps in the loop somewhere, whether that is your RUM vendor resolving them server-side or a step in your own pipeline. Worth noting that shipping source maps carelessly has its own consequences, which I have written about separately.

It names the function, not the reason

LoAF tells you which code was slow. It does not tell you why that code was slow. You still need a profiler for that. What changes is that you now start profiling with a specific function in mind, instead of recording a five second trace and squinting at a flame chart.

And it is still a 50 millisecond threshold API. LoAF fixes the unit and fixes attribution. It does not lower the bar for what counts as slow. Death by a thousand 40 millisecond frames remains invisible, and if your problem is a steady drizzle of mediocre frames rather than occasional terrible ones, this will not surface it.

10 · Practice

What I Actually Do Now

The workflow that came out of all this is short, and I have stopped deviating from it.

INP is the alarm. It is cross-browser, it is a Core Web Vital, and it is the number that goes on the dashboard. When it degrades, LoAF is the first thing I open, because it is the only source that gets me from "interactions are slow" to a function name without a bisect.

From the entry, the first split is always renderStart - startTime against the rest. Script-dominated and layout-dominated frames have completely different fixes and it takes one subtraction to tell them apart. Then forcedStyleAndLayoutDuration on the individual scripts, because layout thrashing is common, expensive, and the single thing I am most likely to find.

The one-line version Long Tasks answers "was something slow". LoAF answers "what was slow, in which phase, and did the user feel it". Keep INP as the metric you alert on, and use LoAF as the thing you open the moment it moves.

I said at the top that our dashboards were green while users were unhappy, and I want to close the loop on that honestly, because the resolution was less cinematic than the setup. There was no single villain. There was a handler doing a synchronous layout read it did not need, a scroll listener that had quietly stopped being passive during a refactor, and a chunk of work that had been split into polite little pieces which all landed in the same frame anyway.

Long Tasks had recorded the last of those as nothing at all. That is the part that stays with me. Not that the old API was imprecise, but that the specific optimisation we had applied - break up the long task, yield more often - had made the telemetry look better while the user experience stayed exactly where it was. We had optimised the measurement.

Measure the frame. It is the thing the user is actually looking at.

Browser support for Long Animation Frames is Chromium-only at the time of writing - verify current availability before depending on it. Timings quoted in this article are illustrative and drawn from sample entries, not from a specific production dataset. The web-vitals library's attribution build is the recommended path for wiring LoAF into INP reporting rather than hand-rolling correlation.