Measure LCP with the PerformanceObserver API
You want one real-user Largest Contentful Paint value per page load, captured without pulling in a dependency, and shipped to your backend before the tab goes away. This is the exact scenario this page solves: a raw recipe that registers a PerformanceObserver for largest-contentful-paint, replays the entries the browser buffered before your script ran, keeps the last candidate, finalizes on the first interaction and on visibilitychange → hidden, and reports the result with navigator.sendBeacon(). It sits under LCP Measurement & Optimization, the reference that covers thresholds and candidate selection in depth. The same lifecycle rules are abstracted away for you by the web-vitals library and the PerformanceObserver patterns — this page shows what that library is doing underneath so you can debug it, trim it, or replace it.
LCP scores against the current Google spec: ≤ 2.5 s is Good, ≤ 4.0 s is Needs Improvement, and > 4.0 s is Poor, aggregated at p75 across your real-user population. The instrumentation below produces the per-load value that feeds that p75; everything hinges on capturing the right entry and finalizing at the right moment.
Prerequisites
- A page served over HTTPS (the Largest Contentful Paint entry type is only exposed in secure contexts).
- A target browser engine that implements
largest-contentful-paint: all Chromium browsers and Firefox 122+. Safari does not emit this entry type, so plan for it to be absent (see Edge cases & gotchas). - An ingestion route that accepts POST bodies and returns quickly — the same one described in Self-Hosted Beacon Collection. The snippets below POST to
/api/v1/rum/ingest. - The script must run as early as possible — inline in the document
<head>, before your hero image and any framework hydration. Thebuffered: trueflag replays entries dispatched before this line executes, but only entries the browser actually recorded for this document. - Cross-origin hero images should carry
crossorigin="anonymous"and be served withAccess-Control-Allow-Origin, orrenderTimewill be withheld for privacy and you will fall back to the coarserloadTime.
How to measure LCP with PerformanceObserver
1. Register the observer with buffered replay
Register before anything paints. The buffered: true flag tells the browser to immediately deliver every largest-contentful-paint entry it already recorded for this document, so an early hero image that rendered before your callback wired up is not lost.
// Run inline in <head>, before the hero image and hydration.
let lcpValue = 0; // best candidate time in ms (DOMHighResTimeStamp)
let lcpEntry = null; // the PerformanceEntry for that candidate
const lcpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// renderTime is withheld (0) for cross-origin images without CORS;
// fall back to loadTime, which is always present.
const candidateTime = entry.renderTime || entry.loadTime;
if (candidateTime > lcpValue) {
lcpValue = candidateTime;
lcpEntry = entry;
}
}
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
Why: Each new candidate the browser promotes is strictly larger than the last, but entries can arrive in one batched callback during the buffered replay. Iterating every entry and keeping the maximum is the robust form — it matches how the web-vitals library and PerformanceObserver handle the same stream and avoids assuming getEntries() returns exactly one item per callback.
2. Read the right fields off the winning entry
The final LCP value is renderTime || loadTime, never startTime alone for cross-origin images. Capture the element identity at the same time so your backend can attribute slow LCP to a specific asset.
function describeEntry(entry) {
if (!entry) return null;
return {
value: entry.renderTime || entry.loadTime, // ms
element: entry.element?.tagName || 'unknown',
// entry.url is the image source for image candidates; '' for text blocks
url: entry.url || '',
size: entry.size, // intrinsic px area of the element
isCrossOriginNoCors: entry.renderTime === 0 // diagnostic flag
};
}
Why: entry.url, entry.size, and entry.element are part of the LargestContentfulPaint interface and cost nothing to read. entry.size lets you spot oversized hero assets, and the renderTime === 0 flag tells you when a missing CORS header is degrading your accuracy rather than a genuinely slow paint.
3. Finalize exactly once on first input and on hidden
The browser stops promoting LCP candidates after the first user interaction and when the tab is backgrounded. You must therefore finalize on whichever happens first: the first input event, or the page being hidden. Guard with a flag so it runs once.
let finalized = false;
function finalizeLCP() {
if (finalized) return;
finalized = true;
// Flush any buffered records the observer hasn't dispatched yet.
lcpObserver.takeRecords().forEach((entry) => {
const t = entry.renderTime || entry.loadTime;
if (t > lcpValue) { lcpValue = t; lcpEntry = entry; }
});
lcpObserver.disconnect();
if (lcpValue > 0) reportLCP(describeEntry(lcpEntry));
}
// First interaction freezes the LCP value; capture it then.
['keydown', 'click', 'pointerdown'].forEach((type) => {
addEventListener(type, finalizeLCP, { once: true, capture: true });
});
// The reliable end-of-life signal across browsers.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') finalizeLCP();
});
// pagehide as a backstop for browsers that skip the final visibilitychange.
addEventListener('pagehide', finalizeLCP);
Why: takeRecords() drains entries the observer queued but has not yet delivered, so you never lose a candidate that landed microseconds before the user clicked. Listening to visibilitychange → hidden (plus pagehide) is the only combination that reliably fires on mobile when the user switches apps — a plain unload or beforeunload handler is unreliable and blocks the bfcache.
4. Report with sendBeacon
Send the finalized payload during teardown with navigator.sendBeacon(), which queues the request in the browser and returns immediately without blocking navigation.
function reportLCP(payload) {
if (!payload) return;
const body = JSON.stringify({
metric: 'LCP',
value: Math.round(payload.value),
element: payload.element,
url: payload.url,
size: payload.size,
corsBlocked: payload.isCrossOriginNoCors,
connection: navigator.connection?.effectiveType || 'unknown',
dpr: window.devicePixelRatio,
page: location.pathname,
ts: Date.now()
});
// sendBeacon survives page teardown; fall back to keepalive fetch.
const endpoint = '/api/v1/rum/ingest';
const ok = navigator.sendBeacon?.(
endpoint,
new Blob([body], { type: 'application/json' })
);
if (!ok) {
fetch(endpoint, { method: 'POST', body, keepalive: true }).catch(() => {});
}
}
Why: A normal fetch started during pagehide is frequently cancelled when the document is destroyed; sendBeacon is purpose-built to outlive it. Sending coarse device context (effectiveType, dpr, pathname) lets your beacon collection pipeline segment p75 without you ever shipping a high-cardinality user agent string.
How this differs from the web-vitals library
The recipe above is deliberately minimal. The web-vitals library wraps the same PerformanceObserver stream but adds correctness behaviors you would otherwise reimplement.
| Concern | Raw recipe (this page) | web-vitals library |
|---|---|---|
| Buffered replay | You set buffered: true manually |
Handled internally |
| Last-candidate selection | You keep the max yourself | onLCP returns the final value |
| Finalize triggers | You wire first-input + visibilitychange + pagehide |
Built in, including bfcache restores |
| Reporting unit | Raw ms, your shape | Rounded value plus id, delta, rating |
| SPA soft navigations | You reset and re-observe (see below) | Supported via the soft-navigation API path |
| Attribution (why it was slow) | Element + url + size only | Attribution build adds load phases |
Reach for the raw recipe when you need a sub-1 KB inline snippet with zero supply-chain surface, or when you are debugging what the library reports. Reach for the library when you want bfcache handling and round-trip attribution for free; the trade-offs are catalogued in Using the web-vitals npm Library Correctly.
Verifying it works
- DevTools Performance panel. Record a load in Chrome DevTools; the Timings track marks an
LCPevent with the chosen element highlighted on hover. The timestamp there should match your capturedvaluewithin a few milliseconds. - Console assertion. Temporarily log inside
finalizeLCP:console.log('LCP', lcpValue, lcpEntry?.element). Trigger it by switching tabs (firesvisibilitychange → hidden) and confirm a single line prints — never two, which would mean your once-guard failed. - Network beacon. In the DevTools Network panel, filter to your ingest path. On tab switch you should see exactly one
ingestrequest of typeping(sendBeacon) carrying the JSON body. Inspect the payload and confirmcorsBlockedisfalsefor same-origin heroes. - RUM dashboard signal. After ingest, your backend should show the new LCP rows landing. Compute p75 over a session window and compare it against the field LCP your provider reports for the same URL — they should agree within sampling noise. Persistent divergence points at clock skew or a sampling gap, both covered in RUM Data Sampling Strategies.
Edge cases & gotchas
- Safari emits nothing. WebKit does not implement
largest-contentful-paint.observe()throws or silently captures zero entries depending on version. Wrap registration intry/catchand treat Safari as “LCP unavailable” rather than reporting a0, which would poison your p75 downward. - Cross-origin images zero out
renderTime. WithoutAccess-Control-Allow-Originandcrossorigin="anonymous"on the<img>, the browser withholdsrenderTimefor privacy and you fall back toloadTime, which is the resource load end, not the paint. The values can differ by tens of milliseconds. Surface thecorsBlockedflag so these loads are filterable. - SPA soft navigations are not new documents.
buffered: trueonly replays entries for the current document, and the observer does not reset on a client-side route change. After a soft navigation you mustdisconnect(), resetlcpValue/lcpEntry/finalized, and register a fresh observer — never reuse a disconnected one. Standard LCP for SPA route views is non-standard, so treat per-route LCP as a custom metric. - Background-tab loads report inflated or absent LCP. If the page loads while hidden (a prerender or a background tab), the first paint may never be promoted. Check
document.visibilityStateat script start and drop loads that began hidden, or your p75 inherits noise. - Double-fire on
visibilitychangeandpagehide. Mobile back/forward cache restores can fire both. Thefinalizedonce-guard prevents a duplicate beacon, but if you support bfcache re-entry you must reset the guard onpageshowwithevent.persisted === true. startTimeis not your value. It equalsrenderTimefor same-origin candidates but is unreliable for cross-origin ones. Always derive the value fromrenderTime || loadTime.
FAQ
Why keep the last entry instead of the first?
The browser promotes a new LCP candidate each time a larger element renders, emitting one entry per promotion. The largest — and therefore final — candidate is the last one it reports before LCP is frozen by the first interaction. Keeping the maximum across all delivered entries yields that final value.
Do I still need pagehide if I already listen to visibilitychange?
Yes, as a backstop. Most teardowns fire visibilitychange → hidden, but some navigation paths and a few older mobile browsers reach pagehide without a final visibility transition. Listening to both, behind a once-guard, closes that gap without double-reporting.
What value should I report when renderTime is 0?
Report loadTime and flag the record as CORS-blocked. loadTime is the resource load completion time rather than the actual paint, so it is slightly less accurate, but it keeps the load in your dataset. Fix the underlying CORS headers to restore precise renderTime measurements.
Related
- LCP Measurement & Optimization — parent cluster covering LCP thresholds, candidate selection, and optimization.
- Optimizing LCP with fetchpriority & preload — once you measure it, make the hero asset arrive sooner.
- Self-Hosted Beacon Collection — the ingestion endpoint that receives the sendBeacon payloads from this recipe.