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.

LCP observer lifecycle The observer replays buffered candidates, keeps the last one, and finalizes on first input or when the page is hidden, then sends a beacon. Register observer buffered: true replays early paints Keep last entry renderTime or loadTime Finalize once first input OR hidden disconnect observer sendBeacon to ingest endpoint non-blocking Browser stops emitting candidates after first interaction — finalize, never wait
The browser stops emitting LCP candidates after the first interaction; finalize promptly and beacon the value to your self-hosted beacon collection endpoint.

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. The buffered: true flag 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 with Access-Control-Allow-Origin, or renderTime will be withheld for privacy and you will fall back to the coarser loadTime.

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

  1. DevTools Performance panel. Record a load in Chrome DevTools; the Timings track marks an LCP event with the chosen element highlighted on hover. The timestamp there should match your captured value within a few milliseconds.
  2. Console assertion. Temporarily log inside finalizeLCP: console.log('LCP', lcpValue, lcpEntry?.element). Trigger it by switching tabs (fires visibilitychange → hidden) and confirm a single line prints — never two, which would mean your once-guard failed.
  3. Network beacon. In the DevTools Network panel, filter to your ingest path. On tab switch you should see exactly one ingest request of type ping (sendBeacon) carrying the JSON body. Inspect the payload and confirm corsBlocked is false for same-origin heroes.
  4. 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 in try/catch and treat Safari as “LCP unavailable” rather than reporting a 0, which would poison your p75 downward.
  • Cross-origin images zero out renderTime. Without Access-Control-Allow-Origin and crossorigin="anonymous" on the <img>, the browser withholds renderTime for privacy and you fall back to loadTime, which is the resource load end, not the paint. The values can differ by tens of milliseconds. Surface the corsBlocked flag so these loads are filterable.
  • SPA soft navigations are not new documents. buffered: true only replays entries for the current document, and the observer does not reset on a client-side route change. After a soft navigation you must disconnect(), reset lcpValue/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.visibilityState at script start and drop loads that began hidden, or your p75 inherits noise.
  • Double-fire on visibilitychange and pagehide. Mobile back/forward cache restores can fire both. The finalized once-guard prevents a duplicate beacon, but if you support bfcache re-entry you must reset the guard on pageshow with event.persisted === true.
  • startTime is not your value. It equals renderTime for same-origin candidates but is unreliable for cross-origin ones. Always derive the value from renderTime || 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.