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.
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.
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.
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:
// 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:
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.
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.
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.
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.
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.
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.
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.
{
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.
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.
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.
Which turns into a diagnosis table. Given an entry, the interesting quantities are differences, not the raw numbers:
| Measure | How to compute it | What a large value means |
|---|---|---|
| Script time | renderStart - startTime | Your JavaScript is the bottleneck. Go read the scripts array. |
| Pre-layout render | styleAndLayoutStart - renderStart | Time in rAF callbacks and ResizeObserver handlers that run at render time. |
| Style and layout | startTime + duration - styleAndLayoutStart | Expensive selectors, or a DOM change that invalidated far more than you expected. |
| Input delay | firstUIEventTimestamp - startTime | How 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.
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.
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.
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.
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, notduration, for the reason in the previous section. - Do not ship the whole
scriptsarray. 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.
sourceFunctionNameplussourceURLis a nice tidy key.sourceCharPositionis 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.
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.
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.
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.
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.
Related essays
- Frontend · Dependencies
I Replaced 12 npm Packages With Browser APIs Nobody Uses. Here's What Happened.
How I audited our production app's dependency tree, replaced a dozen packages with native browser APIs, and cut our bundle by 58%. A practical guide to the platform features hiding in plain sight.
- Observability · Frontend
Frontend Observability is Not a Backend Problem
Your servers are green. Your APIs are healthy. Your dashboards look perfectly fine. And somewhere out there, a real user is staring at a blank white screen. Here is why that gap exists, and what you can actually do about it.
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.