PerformanceObserver API: Every Entry Type Explained (2026 Guide)
The PerformanceObserver API is how you subscribe to Core Web Vitals, resource timings, long tasks, and layout shifts in the browser. Here is every entry type, buffered replay, and a production-ready RUM pipeline.
The PerformanceObserver API is a browser interface that asynchronously delivers performance measurements (Core Web Vitals, resource timings, long tasks, layout shifts, custom marks) to your JavaScript without polling. You register a callback, tell it which entryTypes to watch, and the browser dispatches every matching PerformanceEntry as it happens. In 2026 there are more than a dozen entry types spanning the Performance Timeline, Long Animation Frames, Element Timing, and User Timing specs. I've wired this API into RUM pipelines for the last six years, and honestly, it's still the single most important primitive for real user performance data.
PerformanceObserver is push-based: the browser calls your callback when new entries arrive, so you never miss an event because you were on the wrong tick.
Use observe({ type: 'largest-contentful-paint', buffered: true }). The buffered: true flag replays entries that fired before your script attached, which is critical for LCP, FCP, and layout-shift.
In 2026, browsers expose 13+ entry types. The essential seven for Core Web Vitals RUM are largest-contentful-paint, layout-shift, event, first-input, long-animation-frame, navigation, and paint.
Long Tasks (longtask) are being superseded by Long Animation Frames (long-animation-frame). LoAF gives you script attribution, blocking duration, and render time in one entry.
Never call observer.observe() twice with a mix of type and entryTypes. The API is picky: use type (with buffered) for one type, or entryTypes (array) for many with no buffering. Combining them silently drops entries.
Attach observers as early as possible (inline in the <head> is fine) so slow bundles don't blow past the per-type buffer caps.
What is the PerformanceObserver API?
The PerformanceObserver API is the standard way to subscribe to entries added to the browser's Performance Timeline. A PerformanceEntry is any timestamped record the browser produces about your page: a script fetch, a layout shift, a click that took 340 ms to paint, an image that just became the LCP candidate. Instead of polling performance.getEntriesByType() on a timer (and either missing entries between ticks or wasting CPU), you register an observer and the browser dispatches entries into your callback as they materialize.
Under the hood, the Performance Timeline is a monotonically ordered buffer keyed on startTime (a DOMHighResTimeStamp relative to performance.timeOrigin). Every entry has at least four fields: name, entryType, startTime, and duration. Specific entry types extend that base with their own attributes. LargestContentfulPaint adds element, renderTime, and loadTime; LayoutShift adds value and sources; PerformanceEventTiming adds interactionId and processingStart. You interact with all of them through the same observer surface.
The API landed in Chrome 52 back in 2016. What has changed in 2026 is the sheer number of things you can observe. Chrome 132+ exposes Element Timing, Long Animation Frames, Container Timing, and Soft Navigations behind the same observer contract, and Safari 18 finally shipped long-animation-frame. If you haven't touched this API since the LCP-and-LayoutShift era, you're leaving a lot of signal on the table.
How do you use PerformanceObserver in JavaScript?
Here's the minimum viable observer. This snippet subscribes to Largest Contentful Paint and logs every candidate the browser reports, including the ones that fired before this script executed.
// Guard the API so old browsers do not throw.
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
// list.getEntries() returns every entry in this callback batch.
for (const entry of list.getEntries()) {
console.log('LCP candidate:', {
renderTime: entry.renderTime,
loadTime: entry.loadTime,
size: entry.size,
element: entry.element?.tagName,
url: entry.url, // populated for image/video LCPs
});
}
});
// buffered: true replays entries that fired before observe() ran.
observer.observe({ type: 'largest-contentful-paint', buffered: true });
}
Three subtleties are worth pausing on. First, the callback receives a PerformanceObserverEntryList, not a plain array. Its getEntries() returns all entries buffered since the last dispatch, so if the microtask queue is busy you may get several at once. Second, renderTime can be zero for cross-origin images with no Timing-Allow-Origin header, so you must fall back to loadTime. Third, LCP fires repeatedly (every larger candidate replaces the previous one) and the final value is whichever entry precedes the first user interaction, tab hide, or scroll.
To observe multiple types at once you have two shapes, and they aren't interchangeable:
// Shape A: single type, supports buffered.
observer.observe({ type: 'layout-shift', buffered: true });
// Shape B: multiple types, ignores buffered silently.
observer.observe({ entryTypes: ['navigation', 'resource', 'paint'] });
// Anti-pattern: mixing them throws in some browsers, no-ops in others.
observer.observe({
type: 'longtask',
entryTypes: ['longtask'], // do not do this
buffered: true,
});
My rule: one observer per type when I need buffered entries (Core Web Vitals), one bulk observer for everything else. It costs a few extra object allocations and buys you clarity.
Every PerformanceEntry type explained (2026)
Below is every entry type I regularly subscribe to. I've grouped them by what they measure and noted which browser first shipped them.
navigation
A single PerformanceNavigationTiming entry describing the current document load. Fields I actually use: domainLookupStart/End, connectStart/End, requestStart, responseStart (this is TTFB, minus startTime), responseEnd, domContentLoadedEventEnd, and loadEventEnd. In 2026, activationStart is populated for prerendered pages, so your TTFB math must subtract it or you'll report negative numbers on prerender activations. See our Server-Timing header for TTFB attribution guide for the follow-up on server-side breakdowns.
resource
PerformanceResourceTiming entries: one per subresource fetch (image, script, stylesheet, XHR, fetch). Key fields include initiatorType, nextHopProtocol (finally reliable across all browsers), transferSize, encodedBodySize, decodedBodySize, and responseStatus (Chrome 109+). Cross-origin resources without Timing-Allow-Origin: * return zeros for the internal timings and no body sizes. That isn't a bug, it's the spec.
paint
Two entries: first-paint and first-contentful-paint. Both are single startTime values with duration: 0. FCP is one of the two supporting metrics Google uses to gate Core Web Vitals interpretation, so you should always be capturing it.
largest-contentful-paint
Fires every time a larger candidate is discovered. The last entry before the user interacts or hides the tab is your reported LCP. In 2026 the entry includes element, so you can attribute LCP directly to a DOM node in RUM. Combine with our LCP sub-part deep dive to break the number into TTFB, resource-load, and render delays.
layout-shift
LayoutShift entries carry a value (the shift score) and a sources array. Each source is a LayoutShiftAttribution with the offending node and its previous/current rects. To match Google's CLS calculation you must group entries into session windows (max 5 s wide, gaps of 1 s), sum the values inside each window, and report the largest window. hadRecentInput flags user-initiated shifts that should be excluded.
first-input and event
first-input is a single PerformanceEventTiming capturing the first pointerdown, keydown, or click that changed state. event fires for every event whose duration exceeds a threshold (default 104 ms; configurable via durationThreshold). Both entries expose processingStart, processingEnd, interactionId (0 for non-interactions), and target. Group entries by interactionId to get the full input to paint duration you need for INP.
longtask and long-animation-frame
longtask reports any task ≥50 ms on the main thread with the containing window as attribution, which is usually all it can tell you. long-animation-frame (Chrome 123+, Safari 18+) is the modern replacement: it gives you renderStart, styleAndLayoutStart, blockingDuration, and a full scripts array with per-script invoker, sourceURL, sourceCharPosition, and pauseDuration. If you're still using longtask in 2026, migrate. See our Long Animation Frames guide for the how.
element
PerformanceElementTiming lets you measure the render time of any element you tag with elementtiming="hero-image". Useful for hero content that isn't the LCP but still matters for perceived load. Details in our Element Timing walkthrough.
mark and measure
Your own timings. performance.mark('checkout-form-visible') produces a PerformanceMark; performance.measure('checkout-tti', 'nav-start', 'checkout-form-visible') produces a PerformanceMeasure. Observing them lets you pipe custom application milestones into the same RUM pipeline as browser-generated metrics. Since Chrome 100 you can attach a detail object to marks. Invaluable for adding route, user segment, or experiment metadata.
server-timing and back/forward-cache-restoration
Server-Timing entries aren't a separate entry type. They hang off PerformanceNavigationTiming and PerformanceResourceTiming via the serverTiming array. back-forward-cache-restoration (Chrome 108+) fires when the page is restored from bfcache, giving you a chance to re-arm observers that were paused. Silent gotcha: LCP and layout-shift do not restart on bfcache restore, so your metric for the second view is different from a fresh load.
visibility-state and soft-navigation
visibility-state entries fire on tab visibility transitions, which is critical for deciding when to finalize LCP and CLS. soft-navigation (Chrome 128+ behind an origin trial through 2026) attributes SPA route changes so you can measure Core Web Vitals per client-side navigation. If you ship a React or Vue SPA, subscribe to this and stop reporting one LCP per session.
PerformanceObserver vs performance.getEntries: which should you use?
Both APIs read the same underlying Performance Timeline, but the tradeoffs aren't symmetric. Here's how I decide.
Dimension
PerformanceObserver
performance.getEntries()
Delivery model
Push (async callback)
Pull (synchronous read)
Late-arriving entries
Delivered as they occur
Only if you poll
Historical entries
Only with buffered: true
All buffered entries
Buffer overflow risk
None (entries flow through)
Yes (default 250 resources, then dropped)
Multi-type observation
Single call with entryTypes
One call per type
Performance cost
Zero when idle
O(n) each call
Ideal use
RUM, Core Web Vitals
DevTools, one-shot debugging
The buffer overflow row is the one that bites people. If you rely on performance.getEntriesByType('resource') after page load, you'll silently drop everything past the 250-entry ceiling. You can raise it with performance.setResourceTimingBufferSize(1000), but you still need to drain it. An observer sidesteps the whole issue: entries flow through the callback and you accumulate them yourself.
What does buffered: true actually do?
The buffered flag tells the browser to replay entries that were added to the timeline before your observe() call. For a lazily-loaded RUM script, this is the difference between "captured everything" and "captured whatever happened after byte 40 kB of my bundle parsed." Chrome, Safari, and Firefox all support it for the entry types you actually care about: largest-contentful-paint, layout-shift, first-input, event, paint, navigation, resource, longtask, and long-animation-frame.
Two caveats. First, buffered only works with the single-type { type: 'x' } form, not the { entryTypes: [...] } array form (the array form silently ignores buffered). Second, the buffer has size limits per type. Chrome caps layout-shift at 200 entries and event at 150 entries. If your page shifts more than 200 times before your observer attaches, older entries are gone. That's another reason to attach observers early.
// Right way: one observer per critical type, all with buffered.
function observe(type, cb) {
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) cb(entry);
}).observe({ type, buffered: true });
} catch (err) {
// Unsupported type on this browser. Swallow, do not crash RUM.
}
}
observe('largest-contentful-paint', (e) => report('LCP', e.renderTime || e.loadTime));
observe('layout-shift', (e) => !e.hadRecentInput && addToWindow(e));
observe('first-input', (e) => report('FID', e.processingStart - e.startTime));
observe('event', (e) => e.interactionId && recordInteraction(e));
observe('long-animation-frame', (e) => e.blockingDuration > 50 && recordLoAF(e));
Common PerformanceObserver mistakes I see in production
I audit RUM implementations for a living. Nine times out of ten, the same handful of bugs show up.
Forgetting to disconnect on visibility change
If you keep an observer live in a background tab, callbacks queue up and fire in bulk when the tab returns, often after your LCP finalization logic already ran. Finalize on visibilitychange and pagehide, then call observer.disconnect(). The web-vitals library uses this pattern. See it in action in our RUM with web-vitals guide.
Reading renderTime without falling back
LargestContentfulPaint.renderTime is zero for cross-origin resources without Timing-Allow-Origin. Every RUM implementation must fall back to loadTime: const lcp = entry.renderTime || entry.loadTime;. I hit this exact bug shipping a first-party RUM to a CDN-hosted hero image last year. Forgetting it gives you an artificially low LCP that doesn't match CrUX.
Ignoring hadRecentInput on layout-shift
User-initiated layout changes (a search dropdown opening because the user tapped it) fire layout-shift entries with hadRecentInput: true. Google excludes these from CLS. If your RUM pipeline reports them, your CLS will be inflated and you'll chase phantom shifts for weeks.
Observing both longtask and long-animation-frame
They report overlapping information. Once you have LoAF, drop longtask in modern browsers. The durations are effectively the same, but LoAF adds render, style-and-layout, and script attribution. Keep longtask only as a fallback for browsers that don't yet support LoAF, and de-duplicate before reporting.
Building a production RUM pipeline with PerformanceObserver
So, here's a compact-but-complete pattern I use as a starting point when I don't want to pull in the web-vitals library. It captures LCP, CLS, INP, and LoAF, batches them, and flushes via sendBeacon on tab hide.
// rum.js: load as early as possible in the head, before your framework bundle.
(() => {
if (!('PerformanceObserver' in window)) return;
const queue = [];
const push = (name, value, meta = {}) =>
queue.push({ name, value, ts: performance.now(), ...meta });
// --- LCP: keep the largest until finalization.
let lcp;
observe('largest-contentful-paint', (e) => {
lcp = { value: e.renderTime || e.loadTime, url: e.url, element: e.element?.tagName };
});
// --- CLS: 5s/1s session windows, largest wins.
let cls = 0, windowValue = 0, windowStart = 0, windowEnd = 0;
observe('layout-shift', (e) => {
if (e.hadRecentInput) return;
if (windowValue && (e.startTime - windowEnd > 1000 || e.startTime - windowStart > 5000)) {
cls = Math.max(cls, windowValue);
windowValue = 0;
}
if (!windowValue) windowStart = e.startTime;
windowValue += e.value;
windowEnd = e.startTime;
});
// --- INP: track worst interaction so far.
const interactions = new Map(); // interactionId -> max duration
observe('event', (e) => {
if (!e.interactionId) return;
const prev = interactions.get(e.interactionId) || 0;
interactions.set(e.interactionId, Math.max(prev, e.duration));
});
// --- LoAF: record the worst blocking frame per session.
let worstLoAF = 0;
observe('long-animation-frame', (e) => {
worstLoAF = Math.max(worstLoAF, e.blockingDuration);
});
// --- Finalization: fire on tab hide, never on unload.
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
// CLS: close the open window.
if (windowValue) cls = Math.max(cls, windowValue);
// INP: pick the 98th percentile of interactions, or max if fewer than 50.
const durations = [...interactions.values()].sort((a, b) => b - a);
const inp = durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.02))] || 0;
if (lcp) push('LCP', lcp.value, { url: lcp.url, element: lcp.element });
push('CLS', cls);
push('INP', inp);
push('LoAF', worstLoAF);
navigator.sendBeacon('/rum', JSON.stringify(queue));
}, { once: true });
function observe(type, cb) {
try {
new PerformanceObserver((l) => l.getEntries().forEach(cb))
.observe({ type, buffered: true });
} catch {}
}
})();
Two design choices in that snippet are worth understanding. The INP calculation uses Math.floor(length * 0.02), because Google's INP is the 98th percentile of interactions (or the worst if you have fewer than 50). And I fire on visibilitychange → hidden, not unload, because unload disables bfcache. That's one of the small details that lets your site actually stick in the bfcache. If you care about that, our bfcache optimization guide covers the full checklist.
What's next for PerformanceObserver in 2026 and beyond
Two proposals are worth watching. Soft Navigations extends LCP, CLS, and INP to client-side route changes; it's in an origin trial that runs through 2026 and will likely ship unflagged in Chrome 138. If you run a SPA, you should be measuring against soft navs already. Today's LCP-per-full-load numbers are a lie about actual user experience. The second is Container Timing, a proposed API to attribute render time to arbitrary DOM subtrees rather than only the largest one. It maps neatly to design-system slots and is exactly what Element Timing wishes it were. Both plug into the same PerformanceObserver surface, so the code you write today for LCP will forward-port with zero rewrite. That has always been the API's quiet superpower.
Frequently Asked Questions
What is a PerformanceObserver used for?
PerformanceObserver is used to receive timing entries (Core Web Vitals, resource fetches, long tasks, custom marks) from the browser as they occur. It's the standard way to collect real user monitoring (RUM) data without polling, and it's the foundation of the web-vitals library and every commercial RUM SDK.
Which browsers support PerformanceObserver?
All modern browsers: Chrome 52+, Edge 79+, Firefox 57+, and Safari 11+. Individual entry types have narrower support. long-animation-frame requires Chrome 123 or Safari 18, and soft-navigation is Chrome-only through 2026. Always feature-detect with PerformanceObserver.supportedEntryTypes.
How do I observe multiple entry types with PerformanceObserver?
Use one observe({ entryTypes: ['navigation', 'resource'] }) call for the array form, or register a separate observer per type with observe({ type: 'x', buffered: true }). Prefer the second pattern for Core Web Vitals; the array form silently ignores the buffered flag, so you'll miss entries that fired before your script ran.
What is the difference between PerformanceObserver and performance.getEntries?
PerformanceObserver is push-based and delivers entries as they occur, while performance.getEntries() is a synchronous pull that returns whatever is currently in the timeline buffer. The buffer has size limits (250 resource entries by default), so long-running pages can drop entries. An observer accumulates them in your own JavaScript and never overflows.
Does PerformanceObserver have a performance cost?
Practically none when idle. The browser was already generating these entries; PerformanceObserver only adds a callback dispatch. The cost is in your callback code. If you do heavy work on every event entry you can make INP worse. Use durationThreshold to filter events and defer analytics work with scheduler.postTask or requestIdleCallback.
Learn how the No-Vary-Search response header lets Chrome 141 reuse prefetches and disk cache entries across utm_ and other query parameters, with copy-paste recipes and debugging tips.
A passive event listener promises the browser you won't call preventDefault(), so the compositor thread can scroll immediately without waiting for JavaScript. Here's how I audit and fix the third-party offenders that still ship blocking touchstart and wheel handlers in 2026.
The fetchLater() API queues an HTTP request that the browser fires on bfcache entry, page hide, or a timeout, plugging the reliability gaps that make sendBeacon drop 5-15% of your RUM data on mobile.