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-callback measure 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.

Intersecting a Long Task with Named MeasuresA single long task spanning ninety-two milliseconds on the main thread. Beneath it, three named measures are drawn on the same time axis: a resize callback occupying most of the span, a lazy-image callback occupying a small slice, and a third-party script outside the task entirely. The overlap arithmetic attributes eighty-three milliseconds to the resize callback.One long task, three candidate measures, same clocklongtask — 92ms, attribution: "unknown"4210ms4302msmeasure: resize-callback (83ms)measure: lazy-image (5ms)measure: third-party (outside the task)83 of 92ms attributed to one named function — the ticket now says what to fix, not that something was slow.

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

TypeScript
// 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

TypeScript
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.

Reading the Unattributed RemainderTwo attribution results for a ninety millisecond task. In the first, eighty-three milliseconds are attributed to a named callback and seven remain unaccounted, which means the instrumentation covers the hot path. In the second, only twelve milliseconds are attributed and seventy-eight remain, which means the real cost lies in code that has not been bracketed yet.good coverage — act on the named functionresize-callback 83ms7mspoor coverage — add marks before drawing conclusions12msunattributed 78ms — the cost is somewhere you have not bracketedReporting the remainder is what stops the technique from quietly blaming whichever function happens to be instrumented.

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.

Sampling Rate vs. Confidence in AttributionA trade-off between how many sessions carry the mark-and-measure instrumentation and how confident the resulting attribution is. One per cent of sessions gives a weak signal for rare paths but almost no overhead. Five per cent is the usual balance. Fifty per cent gives strong attribution for rare paths while adding measurable cost to half of all sessions.Choosing a sampling rate for the marks1% of sessionsweak for rare paths, free5% of sessionsthe usual balance50% of sessionsstrong signal, real costRaise the rate temporarily while investigating, then lower it again — the instrumentation is a tool, not a fixture.

Verification Steps

  • Confirm the clocks agree: log a measure's startTime and a long task's startTime from the same load; both are performance.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').length after a minute of scrolling; it should not climb.
  • Confirm the sampling gate works by checking that unsampled sessions produce no measure entries 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 attribution to 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 event entries 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.


↑ Back to PerformanceObserver & Rendering Metrics