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 withelementtiming.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.
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
<!-- 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:
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:
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
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.
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.
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
bufferedis missing, or was passed alongsideentryTypeswhere it is ignored. - CLS looks impossibly good. Check that
hadRecentInputfiltering 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
longtaskentries at all. CheckPerformanceObserver.supportedEntryTypes.includes('longtask'); it is not universal. Its absence is a capability gap, not a fast page. - The beacon never arrives. Sending from
unloadrather thanvisibilitychange, or usingfetchinstead ofsendBeacon. 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.
Related
- Measuring LCP with PerformanceObserver — the full candidate lifecycle and where lazy loading hurts it
- Tracking Layout Shifts with PerformanceObserver — session windows and attributing a shift to a node
- Correlating Long Tasks with Observer Callbacks — turning an anonymous stall into a named function
- PerformanceObserver buffered Entries Explained — why a late observer reports a perfect page
- Callback Throttling & Debouncing — the usual cause once a long task is attributed