IntersectionObserver tells you what the reader can see and ResizeObserver tells you how big it is; PerformanceObserver tells you what all of that cost. It belongs in the same family — a callback-based, browser-scheduled reporter that avoids the polling it replaces — and it is the instrument that turns a suspicion about an observer-driven feature into a number.

Concept Framing

The three observers on this site share a design: you declare interest, the browser does the work it was already doing, and your callback receives a batch. PerformanceObserver differs in one important way. The others report the present state of a live element; PerformanceObserver reports events that already happened, from a buffer the browser has been filling since navigation started.

That difference is the source of the API's single biggest trap. If you register an intersection observer late, you still get told about the element the moment it next crosses a threshold. If you register a performance observer late, the paint you wanted to measure has already occurred, and unless you explicitly ask for the buffer you will be told nothing — and a page that reports no Largest Contentful Paint reads, to a naive dashboard, exactly like a page with a perfect one.

The entry types worth knowing for rendering work are:

  • largest-contentful-paint — reported repeatedly as bigger candidates paint; the metric is the last value before the first interaction.
  • layout-shift — one entry per unexpected movement, with a score and the elements involved.
  • longtask — any task that occupied the main thread for more than 50 ms, which is the budget above which input stops feeling immediate.
  • element — opt-in timing for specific elements you mark with elementtiming.
  • event — input latency, which is what Interaction to Next Paint is built from.

Each of these connects to something the rest of this site is about. Long tasks are usually callbacks doing too much work; layout shifts are usually media loading into unreserved space; LCP is usually the hero image that a lazy loader deferred too aggressively.

Three Observers, Three Questions, One ElementA single card element in the centre with three observers reading from it. IntersectionObserver answers where it is relative to the viewport. ResizeObserver answers how large its box is. PerformanceObserver answers what its arrival cost, in paint time and layout shift. A note records that only the last of these reports events from the past rather than the present.one hero imageon a real pageIntersectionObserveris it in view yet?ResizeObserverhow big is its box now?PerformanceObserverwhen did it paint? (LCP)what did it push? (CLS)what blocked the thread? (longtask)reports the PAST, not the presentWhich is why this one, alone among the three, needs buffered: true to see what happened before it existed.

Spec / Signature Reference Table

Entry type Key fields Fires Typical use
largest-contentful-paint startTime, size, element, url Repeatedly, until first input Identify the element that defines LCP
layout-shift value, hadRecentInput, sources[] Once per unexpected shift Attribute CLS to the elements that moved
longtask startTime, duration, attribution[] Per task over 50 ms Find the frames where interaction stalled
event startTime, processingStart, duration, name Per qualifying interaction Build Interaction to Next Paint
element startTime, identifier, element On paint of a marked element Time a specific hero or headline
resource name, duration, transferSize Per network request Correlate a lazy fetch with its trigger

The observer itself has a small surface: observe({ type, buffered }) for a single type, observe({ entryTypes: [...] }) for several at once — note that buffered is only accepted with the singular type form — plus disconnect() and takeRecords(). PerformanceObserver.supportedEntryTypes is the correct capability test, and it is a plain array of strings, so a missing type is detected with includes.

Step-by-Step Implementation

1. Register as early as the document allows

HTML
<!-- in <head>, before any stylesheet or bundle -->
<script>
  window.__vitals = { lcp: 0, cls: 0, longTasks: 0 };

  if ('PerformanceObserver' in window) {
    const supported = PerformanceObserver.supportedEntryTypes || [];

    if (supported.includes('largest-contentful-paint')) {
      new PerformanceObserver((list) => {
        // LCP is reported repeatedly; the LAST candidate is the metric
        for (const entry of list.getEntries()) window.__vitals.lcp = entry.startTime;
      }).observe({ type: 'largest-contentful-paint', buffered: true });
    }
  }
</script>

Inline and synchronous is deliberate. A deferred module registers after the hero has painted, and without buffered: true that page would report an LCP of zero.

2. Accumulate layout shift correctly

Cumulative Layout Shift is not a simple sum. Shifts that follow a user action within 500 ms are excluded, and the score is the largest one-second session window rather than the total for the page:

TypeScript
interface ShiftEntry extends PerformanceEntry { value: number; hadRecentInput: boolean; }

let sessionValue = 0;
let sessionEntries: ShiftEntry[] = [];
let worstSession = 0;

new PerformanceObserver((list) => {
  for (const raw of list.getEntries()) {
    const entry = raw as ShiftEntry;
    if (entry.hadRecentInput) continue;             // the reader caused it — not counted

    const first = sessionEntries[0];
    const last = sessionEntries[sessionEntries.length - 1];

    const continues = sessionEntries.length > 0
      && entry.startTime - last.startTime < 1000
      && entry.startTime - first.startTime < 5000;

    if (continues) {
      sessionValue += entry.value;
      sessionEntries.push(entry);
    } else {
      sessionValue = entry.value;
      sessionEntries = [entry];
    }

    if (sessionValue > worstSession) worstSession = sessionValue;
  }
}).observe({ type: 'layout-shift', buffered: true });

3. Attribute a long task to your own code

A longtask entry will not name your function. Bracket the callback yourself and correlate afterwards:

TypeScript
const ro = new ResizeObserver((entries) => {
  performance.mark('ro:start');
  applyLayout(entries);                       // the work under suspicion
  performance.mark('ro:end');
  performance.measure('resize-callback', 'ro:start', 'ro:end');
});

new PerformanceObserver((list) => {
  for (const task of list.getEntries()) {
    const overlapping = performance
      .getEntriesByName('resize-callback', 'measure')
      .filter((m) => m.startTime < task.startTime + task.duration
                  && m.startTime + m.duration > task.startTime);

    if (overlapping.length) {
      const owned = overlapping.reduce((sum, m) => sum + m.duration, 0);
      console.warn(`long task ${task.duration.toFixed(0)}ms — ${owned.toFixed(0)}ms in resize callbacks`);
    }
  }
}).observe({ type: 'longtask', buffered: true });

That turns an anonymous 90 ms stall into an attributable one, which is the difference between a ticket someone can act on and a ticket that gets closed as unreproducible.

4. Report once, on the way out

TypeScript
addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  navigator.sendBeacon('/vitals', JSON.stringify({
    lcp: Math.round(window.__vitals.lcp),
    cls: Number(worstSession.toFixed(4)),
    path: location.pathname,
  }));
}, { once: true });

visibilitychange fires on mobile task-switching where unload does not, and sendBeacon survives the document being discarded — the same reasoning as reporting an ad impression.

Registration Time vs. What Gets ReportedA page-load timeline with three paint events and one long task occurring before the application bundle finishes evaluating. An observer registered at that point without the buffered flag receives nothing from before its registration, so the page reports a Largest Contentful Paint of zero. The same observer with buffered set to true receives all four earlier entries replayed into its first callback.FCPLCP #1long taskLCP #2bundle evaluates — observer registers hereobserve({ type })0 entries — the page reports LCP 0 and looks perfectobserve({ type, buffered: true })all 4 earlier entries replayed at onceA zero reading and a great reading are indistinguishable in a dashboard, which is why this flag is not an optimisation —it is the difference between measuring the page and quietly measuring nothing.

Configuration Variants

Goal Configuration Why
One metric, full history observe({ type: 'layout-shift', buffered: true }) buffered only works with singular type
Several metrics, one callback observe({ entryTypes: ['longtask', 'event'] }) No buffering available; register early
Stop LCP at first input disconnect() inside a one-shot pointerdown handler The metric is defined as the value before interaction
Time a specific element <img elementtiming="hero"> + type: 'element' Gives you a named entry instead of guessing which node was largest
Sampled reporting Register only for a percentage of sessions Field data rarely needs every session; the beacon volume does add up

Edge Cases & Gotchas

buffered is silently ignored with entryTypes. Passing both looks reasonable and compiles fine, but the buffer is only replayed for the singular type form. A multi-type observer registered late reports partial data with no warning.

LCP stops being updated after the first interaction, not after load. A page the reader scrolls immediately can have a lower reported LCP than the same page left alone, because scrolling freezes the metric. This is by design, and it means synthetic tests that scroll early under-report.

layout-shift entries have no element field. They have a sources array, and each source has node, previousRect and currentRect. Code that reaches for entry.element gets undefined and attributes every shift to nothing.

A longtask entry's attribution is coarse. For same-origin script it usually names only the container, and for cross-origin frames it reports unknown. This is a deliberate privacy boundary, and it is why the mark-and-measure correlation above exists.

takeRecords() before disconnect(). Entries queued but not yet delivered are lost when you disconnect. If a beacon is being sent from a teardown path, drain first: for (const e of po.takeRecords()) handle(e); po.disconnect();.

Metrics are per-document, not per-route. In a single-page application, LCP and CLS relate to the initial navigation. Route changes do not reset them, so a soft navigation that pushes content around adds to the same CLS score the first page accumulated — one more reason to be careful with observer-driven layout writes.

Performance Expectations

It is fair to ask what the instrumentation itself costs, since the whole point is not to slow the page down while measuring it.

The observer registration is effectively free: the browser is already recording these entries into its performance timeline whether or not anyone is listening, so observe() only attaches a consumer to a buffer that exists regardless. The cost lands entirely in your callback, and it is proportional to how much you do per entry.

That gives a clear budget. A layout-shift observer on a busy page may receive a few dozen entries across a session, and the session-window arithmetic above is a handful of comparisons per entry — immeasurable. An event observer on a page with heavy interaction can receive hundreds, and anything more than an increment there starts to show up in the very metric it is measuring. The rule that keeps instrumentation honest is to accumulate in the callback and compute at report time: keep the callback to assignments and additions, and do percentile maths, string formatting and JSON serialisation once, inside the visibilitychange handler.

Two further habits keep the overhead bounded on long-lived pages. First, disconnect observers whose metric has been finalised — the LCP observer has nothing left to say after the first interaction, so keeping it attached is pure cost. Second, cap any array you accumulate; a session-window buffer that grows unboundedly on a page left open all day is the same retention problem described in preventing memory leaks in long-running observers, with the added indignity that the leak is in the code meant to detect leaks.

Sampling deserves a mention as well. Field metrics describe a distribution, and a distribution is estimated perfectly well from a fraction of sessions. Reporting from ten per cent of visits reduces beacon volume by an order of magnitude while barely moving the percentiles you actually make decisions on — as long as the sampling decision is made once per session and stored, rather than re-rolled per page view, which would bias the sample toward short sessions.

Where Each Metric Is FinalisedFour metrics on one page-load timeline showing when each stops changing. First Contentful Paint is fixed at the first paint. Largest Contentful Paint keeps updating until the first interaction. Cumulative Layout Shift accumulates for the whole document lifetime. Interaction to Next Paint updates with every qualifying interaction, so both of the last two must be reported at visibility change rather than at load.FCP fixedLCP updates until first inputfirst inputCLS accumulates for the whole document lifetimeINP updates on every qualifying interactionTwo of these are never final at load — which is why the beacon goes on visibilitychangenavigation → session end

Framework Integration Patterns

React. Register outside the component tree. An effect-based registration is already too late for paint metrics, and a component that unmounts would disconnect an observer that should live for the whole document. Put the inline head script in your HTML template and expose the collected values through a context if the UI needs them.

Vue and Angular. The same reasoning applies: this instrumentation belongs to the document, not to a component. In Angular, register in main.ts before bootstrapApplication, and use NgZone.runOutsideAngular so a metric callback never schedules change detection — the same discipline described in Angular observer directives.

Next.js and other SSR frameworks. PerformanceObserver is a browser global, so the guard rules from SSR & hydration observer safety apply unchanged. The head-script approach sidesteps the problem entirely, because that script only ever runs in a browser.

Debugging Checklist

  • LCP reports 0. The observer registered after the paint and buffered is missing, or was passed alongside entryTypes where it is ignored.
  • CLS looks impossibly good. Check that hadRecentInput filtering is not accidentally excluding everything — a page that fires synthetic input events early will suppress real shifts.
  • CLS looks impossibly bad. You are summing all entries instead of using session windows. A long-lived page accumulates many small shifts that the windowed definition does not add together.
  • No longtask entries at all. Check PerformanceObserver.supportedEntryTypes.includes('longtask'); it is not universal. Its absence is a capability gap, not a fast page.
  • The beacon never arrives. Sending from unload rather than visibilitychange, or using fetch instead of sendBeacon. Both fail on mobile task-switch.
  • Numbers disagree with a lab tool. Lab tools measure one cold load on one device; field data is a distribution across real ones. They are supposed to differ — compare percentiles, not single values.

FAQ

Why does my LCP callback fire several times?

That is the defined behaviour. The browser reports a new largest-contentful-paint entry every time a larger candidate paints, so a page whose hero image arrives late produces two or three entries. The metric is the last value observed before the first user interaction, so keep overwriting a variable rather than acting on the first entry.

What does buffered: true actually do?

It replays entries the browser already recorded before your observer existed. Without it, an observer registered after a late-loading bundle sees nothing for paint metrics that happened during page load, and reports a suspiciously perfect score. It is not a performance option; it is the difference between measuring and missing.

How is layout-shift different from what ResizeObserver reports?

ResizeObserver tells you an element's own box changed size. A layout-shift entry tells you that an element moved without a user action, and quantifies how much of the viewport was affected. An element can resize without shifting anything, and elements can shift without any of them resizing — they answer different questions and are frequently both needed.

Can PerformanceObserver tell me which observer callback caused a long task?

Not directly — a longtask entry names a container, not a function. The practical technique is to bracket your callback with performance.mark and performance.measure, then correlate the measure's timestamp with the long task's start time. That turns "something took 90 ms" into "the resize callback took 84 ms of it".

Does registering several PerformanceObservers cost more than one?

Marginally, and clarity is usually worth it. One observer per entry type keeps each callback's logic simple and lets you unregister a type independently — for example disconnecting the LCP observer at the first interaction while the layout-shift observer keeps running for the session.


↑ Back to Core Observer Fundamentals & Browser APIs