longtask tells you the main thread was blocked for 90 ms. It does not tell you by what. Marks and measures close that gap, and they are the difference between a performance ticket someone can act on and one that gets closed as unreproducible.
Problem / Scenario Context
Field data shows long tasks concentrated on the product listing page, concentrated during scroll. The page runs an IntersectionObserver for lazy images, a ResizeObserver for card layout, an analytics impression observer and a third-party script. Any of them could be responsible.
The longtask entry's attribution array is not going to settle it. For same-origin script it typically reports only the containing frame, and for cross-origin content it reports unknown — a deliberate privacy boundary rather than an oversight. Guessing from there means disabling features one at a time in production, which is slow and unpopular.
Mechanics Explanation
The User Timing API provides the missing half. performance.mark(name) records a timestamp; performance.measure(name, start, end) records a named interval between two marks. Both land in the same performance timeline the long-task entries come from, which means they share a clock and can be compared directly.
Bracketing a callback therefore produces intervals you can intersect against long tasks:
- A long task from 4210 ms to 4302 ms.
- A
resize-callbackmeasure from 4215 ms to 4298 ms.
The overlap is 83 ms of a 92 ms task, and the ticket now names a function.
The one design constraint is that marks and measures are not free — they allocate entries in a buffer. That makes them ideal for development and for a sampled slice of production traffic, and a poor idea to leave on unconditionally for every session.
Comparison Table: Attribution Sources
| Source | Granularity | Available in the field |
|---|---|---|
longtask.attribution |
The containing frame, often unknown |
Yes |
| DevTools Performance profile | Exact stack | No — local only |
performance.measure intersection |
The function you chose to name | Yes |
| Sampling a JS self-profiler | Statistical stacks | Behind a header, limited support |
| Disabling features one at a time | Binary | Yes, but slow and disruptive |
Minimal Reproducible Example
// What the entry gives you on its own
new PerformanceObserver((list) => {
for (const t of list.getEntries()) {
console.log(t.duration, (t as any).attribution?.[0]?.name); // 92, "unknown"
}
}).observe({ type: 'longtask', buffered: true });
Production-Safe Solution
const SAMPLED = Math.random() < 0.05; // 5% of sessions carry the marks
function instrument<T>(name: string, fn: () => T): T {
if (!SAMPLED) return fn();
const start = `${name}:start`;
performance.mark(start);
try {
return fn();
} finally {
performance.measure(name, start); // end defaults to now
performance.clearMarks(start); // keep the buffer bounded
}
}
const ro = new ResizeObserver((entries) => instrument('resize-callback', () => applyLayout(entries)));
const io = new IntersectionObserver((entries) => instrument('lazy-image', () => loadVisible(entries)));
if (SAMPLED) {
new PerformanceObserver((list) => {
for (const task of list.getEntries()) {
const taskEnd = task.startTime + task.duration;
const inside = performance.getEntriesByType('measure')
.filter((m) => m.startTime < taskEnd && m.startTime + m.duration > task.startTime);
const byName = new Map<string, number>();
for (const m of inside) {
const overlap = Math.min(taskEnd, m.startTime + m.duration) - Math.max(task.startTime, m.startTime);
byName.set(m.name, (byName.get(m.name) ?? 0) + Math.max(0, overlap));
}
const ranked = [...byName].sort((a, b) => b[1] - a[1]);
report('longtask', {
duration: Math.round(task.duration),
attributed: ranked.slice(0, 3).map(([n, ms]) => ({ n, ms: Math.round(ms) })),
unattributed: Math.round(task.duration - ranked.reduce((s, [, ms]) => s + ms, 0)),
});
performance.clearMeasures(); // do not let measures accumulate
}
}).observe({ type: 'longtask', buffered: true });
}
The unattributed figure is the most useful number in that payload. If it is small, the instrumentation covers the hot paths. If it is most of the task, the cost is somewhere you have not bracketed yet — which is itself the finding.
Interpreting the Result
Attribution is only half the job; deciding what to do with it is the other half, and the same 83 ms figure points at different fixes depending on what the callback was doing.
If the named callback is a ResizeObserver handler that reads geometry, the cost is almost certainly forced synchronous layout rather than the handler's own arithmetic — the fix is to stop reading the DOM inside it, not to make the arithmetic faster. The tell is that the attributed time scales with the number of entries in the batch rather than with the complexity of the work.
If the named callback is an IntersectionObserver handler doing per-entry DOM writes, the cost is usually the write amplification of a fine-grained threshold array. Reducing the thresholds is a larger win than optimising the body, because it removes callbacks entirely rather than shortening them.
If the attributed function is a framework render triggered from a callback, the observer is not the problem at all — it is the trigger for a state update whose render is expensive. That is worth separating explicitly in the report, because the two have completely different owners.
And if the remainder dominates, resist the temptation to widen the bracket until something is captured. Bracketing more code will always attribute more time; it does not follow that the newly-bracketed code is the cost. Add marks around one suspected region at a time and watch whether the remainder falls by roughly what the new measure gained. When it does, the attribution is real; when the remainder barely moves, the time is being spent somewhere else entirely and the search continues.
Verification Steps
- Confirm the clocks agree: log a measure's
startTimeand a long task'sstartTimefrom the same load; both areperformance.now()-based and directly comparable. - Confirm the bracket covers the whole callback by adding a deliberate 200 ms busy loop inside it and checking the attributed figure moves by 200.
- Confirm the buffer stays bounded by logging
performance.getEntriesByType('measure').lengthafter a minute of scrolling; it should not climb. - Confirm the sampling gate works by checking that unsampled sessions produce no
measureentries at all.
Common Mistakes to Avoid
- Leaving marks on for every session. They allocate; sample instead.
- Never calling
clearMeasures. The list grows for the life of the page and the intersection scan gets slower with it. - Trusting
attributionto name your function. It names a container by design; the privacy boundary is not going to move. - Attributing without reporting the remainder. Without it, the technique always blames whatever you happened to instrument.
- Optimising a long task that is not on the interaction path. A 90 ms task during idle time costs the reader nothing; correlate against
evententries before acting.
FAQ
Why does longtask attribution say unknown?
Because naming the exact script would leak information across origins, the attribution field deliberately reports only the containing frame, and often not even that. It is a privacy boundary rather than a gap that will be filled, which is why marks and measures are the practical route to attribution.
Do marks and measures cost enough to matter?
Each one allocates an entry in the performance timeline, which is cheap individually and noticeable when a callback fires sixty times a second for the life of a page. Gate them behind a sampling decision so a small share of sessions carry the instrumentation and the rest carry none.
What does the unattributed remainder tell me?
How much of the blocking time your instrumentation does not cover. A small remainder means the named function really is the cost. A large one means the real work is in code you have not bracketed, and acting on the named function would be optimising the wrong thing.
Should I optimise every long task I find?
No. A long task during idle time costs the reader nothing measurable. Correlate long tasks against interaction entries first, and prioritise the ones that overlap an input — those are the ones that turn into a visible delay between a tap and a response.
Related
- PerformanceObserver & Rendering Metrics — the observer family this technique sits inside
- Callback Throttling & Debouncing — the usual fix once a callback is named
- Batching DOM Reads and Writes in Observer Callbacks — the usual cause when the named callback reads layout
↑ Back to PerformanceObserver & Rendering Metrics