Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Measure Real User Performance with PerformanceObserver

C
Carlo Fontanos
· 2 min read

Your site is fast on your dev machine - an M-series laptop on fiber. Your users have mid-range Androids on hotel wifi. The gap between those realities is invisible in DevTools, and PerformanceObserver is the browser's built-in way to see it: performance entries streamed to you as they happen, on real devices.

Long tasks: find your main-thread hogs

new PerformanceObserver((list) => {
    for (const task of list.getEntries()) {
        if (task.duration > 100) {
            reportMetric({ type: 'long-task', duration: Math.round(task.duration) });
        }
    }
}).observe({ type: 'longtask', buffered: true });

Any task over 50ms blocks input and gets reported here. Aggregate these by page and you have a ranked list of where real users' devices choke - which frequently disagrees with where the profiler says your fast machine chokes. (Fixes usually involve chunking work or deferring it to idle time.)

The buffered flag: metrics from before your script loaded

Analytics scripts load late; LCP often happens early. buffered: true replays entries that occurred before you subscribed - without it, you'd systematically miss the exact events you care about. It's the single most important option in this API.

Core Web Vitals, hand-rolled

// Largest Contentful Paint - keep the LAST entry
new PerformanceObserver((list) => {
    const last = list.getEntries().at(-1);
    vitals.lcp = Math.round(last.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

// Cumulative Layout Shift - sum shifts without recent input
new PerformanceObserver((list) => {
    for (const e of list.getEntries()) {
        if (!e.hadRecentInput) vitals.cls = (vitals.cls || 0) + e.value;
    }
}).observe({ type: 'layout-shift', buffered: true });

Ship the totals on visibilitychange with sendBeacon, aggregate percentiles in a small PHP endpoint, and you have field vitals with no vendor. (Honest recommendation: Google's tiny web-vitals library encodes the exact spec edge cases - session windows for CLS, interaction grouping for INP - and I use it in production. But knowing the raw API means you can also observe custom things libraries don't cover: your own performance.mark() pairs, resource timings for a specific CDN, slow event handlers via the event timing API.)

Reality checks

  • longtask, layout-shift and LCP entries are Chromium-centric; Firefox and Safari support subsets. Field data skews accordingly - fine for finding problems, just don't compare cross-browser percentiles naively.
  • Sample. You don't need every session; 10% keeps payloads and storage sane.
  • Always pass { type, buffered } per observer rather than the multi-type entryTypes form - buffered only works with the singular form.

Lab tools optimize the site you test. Field observation optimizes the site people actually experience. You want both, but only one of them requires shipping code - this one.

C
Written by Carlo Fontanos

Full-stack web developer sharing practical tutorials and building tools that ship.

Got something on your mind?

My inbox is open - no forms disappearing into the void here.

  • Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
  • Hire me for a project Have something custom in mind? Let's talk scope and timelines.
  • Product support Bought something here? I'll help you get it running.

I usually reply within 1-2 business days.

Message sent!

Your details are only used to reply to you.

Keep reading