EmpireUI
Get Pro
← Blog8 min read#performance#core-web-vitals#javascript-api

PerformanceObserver API: Measuring LCP, INP, CLS in Code

Stop guessing your Core Web Vitals. The PerformanceObserver API lets you measure LCP, INP, and CLS directly in your JavaScript — no external tools needed.

Browser developer tools showing performance metrics and timing graphs on a monitor

Why PerformanceObserver Exists (and Why You Should Care)

Honestly, most developers ship features and then vaguely hope Lighthouse gives them a green score. That's not a performance strategy — that's optimism with a build step.

The PerformanceObserver API landed in browsers around 2016 and became fully stable by Chrome 52, Firefox 57, and Safari 11. It's a native browser interface that lets your JavaScript code observe performance timeline entries as they happen — things like paint events, layout shifts, long animation frames, and resource loads.

The difference between using PerformanceObserver and running a Lighthouse audit is the difference between monitoring production and testing a lab sample. Lighthouse runs on an empty cache, throttled CPU, nobody-else-is-running-JavaScript conditions. Your real users are on a 2021 Android mid-range device, with 24 tabs open, on a hotel WiFi. The PerformanceObserver API captures what actually happens to them.

It's also the backbone of libraries like web-vitals from the Chrome team. If you've ever used that package, you've indirectly used PerformanceObserver. This article shows you what's happening under the hood.

The Basic Observer Pattern: How the API Works

The API follows a familiar observer pattern. You create a PerformanceObserver instance, pass it a callback, then call .observe() with a config object specifying which entry types you want. When the browser records a matching entry, your callback fires.

Entry types are strings like 'largest-contentful-paint', 'layout-shift', 'long-animation-frame', 'resource', 'navigation', and 'paint'. Each entry type has a specific shape — they all extend PerformanceEntry but add their own fields.

Here's the minimal version that logs everything about LCP:

const observer = new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry.element);
  }
});

observer.observe({ type: 'largest-contentful-paint', buffered: true });

The buffered: true flag is important. Without it, you only catch future entries. With it, the browser replays any entries that fired before your observer was attached — handy when your script loads asynchronously.

Measuring Largest Contentful Paint (LCP) Accurately

LCP is the timestamp when the largest image or text block in the viewport finishes rendering. Google's threshold is 2500ms for 'good'. The tricky part: LCP isn't a single event. The browser may fire multiple LCP candidates as the page loads — a hero image, then a larger headline below it. The last reported entry before the user interacts is the one that counts.

This matters more than people realize. If you read the first LCP entry and report it, you might log a small thumbnail that rendered in 400ms, while the actual hero image (the element users see) came in at 3200ms.

let lcpValue = 0;
let lcpElement = null;

const lcpObserver = new PerformanceObserver((entryList) => {
  // Keep overwriting — last entry wins
  const entries = entryList.getEntries();
  const last = entries[entries.length - 1];
  lcpValue = last.startTime;
  lcpElement = last.element;
});

lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

// Finalize on first user interaction
['click', 'keydown', 'scroll'].forEach((type) => {
  window.addEventListener(
    type,
    () => {
      lcpObserver.disconnect();
      console.log(`LCP: ${lcpValue.toFixed(1)}ms`, lcpElement);
    },
    { once: true, passive: true }
  );
});

Notice we disconnect the observer on first user interaction. That's exactly how the Chrome team's web-vitals library handles it. The user interacting is the signal that page load is effectively done from a perception standpoint. After that, any new large content is part of dynamic behavior, not initial load.

Tracking Cumulative Layout Shift (CLS) Without Losing Your Mind

CLS is the sum of all unexpected layout shifts during the page's lifetime. The formula is impact fraction × distance fraction per shift, then accumulated. A 'good' score is below 0.1. The implementation catches 'layout-shift' entries and sums the value property — but you only sum shifts that don't have recent user input (within 500ms), because intentional interactions like clicking an accordion should count against you.

let clsScore = 0;
let sessionValue = 0;
let sessionEntries = [];
let lastEntryTime = 0;

const clsObserver = new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    // Skip shifts caused by user input
    if (entry.hadRecentInput) continue;

    const firstEntry = sessionEntries[0];
    const lastEntry = sessionEntries[sessionEntries.length - 1];

    // New session window: > 1s since last entry, or > 5s total window
    if (
      sessionEntries.length &&
      (entry.startTime - lastEntry.startTime > 1000 ||
        entry.startTime - firstEntry.startTime > 5000)
    ) {
      clsScore = Math.max(clsScore, sessionValue);
      sessionValue = 0;
      sessionEntries = [];
    }

    sessionEntries.push(entry);
    sessionValue += entry.value;
  }

  clsScore = Math.max(clsScore, sessionValue);
  console.log('CLS so far:', clsScore.toFixed(4));
});

clsObserver.observe({ type: 'layout-shift', buffered: true });

The session window logic above (1s gap, 5s max) is the official algorithm as of the 2021 update to the CLS spec. Before that update, CLS accumulated forever, which penalized long-lived SPAs harshly. If your page has a lot of animations — like the ones you'd build with canvas animations — make sure they're GPU-composited transforms so they don't trigger layout shifts.

One practical gotcha: fonts loading late cause CLS. If you're using a custom typeface and see unexpected layout shift values, that's almost always the font swap. Use font-display: optional or preload your font files.

Measuring Interaction to Next Paint (INP)

INP replaced FID (First Input Delay) as a Core Web Vital in March 2024. Where FID only measured the delay before the browser started processing the first interaction, INP measures the full round-trip: the time from user input to the next frame painted in response. It covers all interactions during the page lifetime and reports the worst one (with some outlier trimming).

The entry type is 'interaction', which is part of the Event Timing API. Each entry has a duration property (in milliseconds, rounded to the nearest 8ms to prevent timing attacks) and an interactionId to deduplicate pointer-down/pointer-up pairs.

const interactions = new Map();

const inpObserver = new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.interactionId) continue;

    const existing = interactions.get(entry.interactionId);
    if (!existing || entry.duration > existing.duration) {
      interactions.set(entry.interactionId, entry);
    }
  }
});

inpObserver.observe({ type: 'event', durationThreshold: 16, buffered: true });

// Report INP at page hide
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;

  const allDurations = [...interactions.values()]
    .map((e) => e.duration)
    .sort((a, b) => b - a);

  // Trim top outliers (p98 approximation for < 50 interactions)
  const inp = allDurations[0] ?? 0;
  console.log(`INP: ${inp}ms`);
  inpObserver.disconnect();
});

The durationThreshold: 16 setting filters out extremely fast interactions (under one frame at 60fps). Without it, you'd accumulate hundreds of fast click entries and pollute your data. A 'good' INP is under 200ms. If you're building UI-heavy apps with things like Lottie animations or WebGL backgrounds, run INP measurements on your actual interaction-heavy components — animation libraries on the main thread can push INP past 500ms on mid-range hardware.

Sending Metrics to Your Analytics Endpoint

Measuring in the browser is only half the job. You need to get the numbers into a database. The go-to technique is navigator.sendBeacon() on the visibilitychange event — when the user navigates away or closes the tab. sendBeacon is fire-and-forget, non-blocking, and guaranteed to send even during page unload, unlike fetch with keepalive which has browser-specific quirks.

function reportMetric(name, value, rating) {
  const body = JSON.stringify({
    name,       // 'LCP' | 'CLS' | 'INP'
    value,      // raw ms or unitless score
    rating,     // 'good' | 'needs-improvement' | 'poor'
    url: location.href,
    ts: Date.now(),
  });

  navigator.sendBeacon('/api/vitals', new Blob([body], { type: 'application/json' }));
}

// Rating thresholds (official Google values)
function getRating(name, value) {
  const thresholds = {
    LCP: [2500, 4000],
    CLS: [0.1, 0.25],
    INP: [200, 500],
  };
  const [good, poor] = thresholds[name];
  if (value <= good) return 'good';
  if (value <= poor) return 'needs-improvement';
  return 'poor';
}

On the server side, a single Postgres table with columns (name, value, rating, url, ts, session_id) is all you need to start. Aggregate the p75 per URL weekly and you've got a real performance dashboard without paying for a third-party RUM service.

If you're building a component library — or customizing components from a system like Empire UI — this kind of metric reporting belongs in a shared useWebVitals hook, not scattered across individual pages. Write it once, use it everywhere.

What PerformanceObserver Can't Tell You

There are gaps. The API can't directly measure server response time in a way that maps to the user's network quality — that's in the navigation entry as responseStart - requestStart, but it blends DNS, TCP, TLS, and TTFB into a single number. There's no way to distinguish "slow server" from "user is on 2G" without additional signals like navigator.connection.effectiveType.

Cross-origin iframes are also invisible. If your third-party chat widget or payment form is in an iframe, it's generating its own performance entries in its own context. You won't see those entries from the parent page. This is worth knowing if your layout shift score keeps coming in high and you can't find the culprit in your own code.

And browser support has some texture to it. 'interaction' entries (for INP) require Chrome 96+ or Edge 96+. Safari 15.4 got 'layout-shift'. Firefox still doesn't support 'largest-contentful-paint' as of late 2026. If you're targeting a broad audience, add guards: PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint') before attaching. Don't just assume every browser will have every type.

Is this more work than dropping in the web-vitals npm package? Yes. But understanding what the package actually does means you can extend it, debug it when it reports surprising values, and integrate measurements into your own components — like the animated transitions in a parallax scrolling layout — where the standard tooling gives you no visibility at all.

Integrating Measurements Into Your Component Workflow

If you're working in React, the cleanest pattern is a useEffect in a top-level layout component that sets up all three observers once, accumulates the metrics, and reports on unmount or visibility change. Don't initialize multiple observers in child components — you'll end up with duplicate entries and inflated numbers.

For style-heavy applications built with glassmorphism effects or complex theme toggles, run your INP measurements while toggling themes and activating backdrop filters. backdrop-filter: blur(12px) combined with heavy compositing layers is a known INP killer on older GPUs. The observer will catch it. Lighthouse won't, because Lighthouse doesn't interact with your UI.

The broader principle is: treat performance measurements like you treat error monitoring. You wouldn't ship to production without Sentry or equivalent. You shouldn't ship without some form of real-user performance data either. The PerformanceObserver API gives you that infrastructure for free — no SaaS subscription, no SDK with opaque internals, just a browser API and a few dozen lines of JavaScript.

FAQ

Does PerformanceObserver work in all major browsers?

The base API works in Chrome 52+, Firefox 57+, and Safari 11+. However, specific entry types vary. 'largest-contentful-paint' is Chrome/Edge only as of late 2026. 'layout-shift' is in Chrome 77+ and Safari 15.4+. Firefox still lacks both. Always check PerformanceObserver.supportedEntryTypes before observing.

What's the difference between PerformanceObserver and performance.getEntriesByType()?

getEntriesByType() is synchronous and reads already-recorded entries from the performance buffer. PerformanceObserver is asynchronous and fires a callback as new entries are added. For metrics like LCP and CLS that accumulate over time, the observer pattern is required — you can't poll for them after the fact reliably because the buffer may have been cleared.

Why does my LCP value differ from what Lighthouse reports?

Lighthouse runs in a simulated environment: throttled CPU (4x slowdown), throttled network (slow 4G), empty cache, no extensions. PerformanceObserver captures real-user conditions. A 1200ms LCP in Lighthouse and a 3800ms LCP in your RUM data both represent real scenarios — the RUM number is what your actual users experience.

How do I avoid double-counting layout shifts caused by user interactions?

Filter entries where entry.hadRecentInput === true. The browser sets this flag for 500ms after any pointer or keyboard event. Intentional layout changes from user actions (opening a dropdown, expanding an accordion) should not count against your CLS score, and this flag is the mechanism for excluding them.

Can I measure INP per-component, not just page-wide?

Partially. The PerformanceEntry for interaction events includes a target property pointing to the DOM element that received the event. You can read entry.target to identify which component triggered the slow interaction. This lets you narrow down whether your 400ms INP is coming from a heavy dropdown, a canvas element, or a third-party widget.

What's durationThreshold in the event observer and what value should I use?

durationThreshold filters out entries shorter than the given milliseconds. The default is 104ms, which filters aggressively. For INP measurement, 16ms (one frame at 60fps) is standard practice — it catches any interaction that causes even a single missed frame. Values below 16 have no practical meaning since event timing is rounded to 8ms increments.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Core Web Vitals in 2026: LCP, INP, CLS with Real Next.js FixesCore Web Vitals in 2026: LCP, CLS, INP — Measure and FixNext.js Image Component Deep Dive: All Props, Performance ImpactNext.js Analytics: Measuring Real User Web Vitals