You Installed a Library for Numbers PerformanceObserver Already Has.
The web-vitals npm package gets credit for data your browser already computes. Here's what three PerformanceObserver calls give you for free — and the one piece of math it quietly saves you from getting wrong.

Your bundle analyzer flags a new dependency after a routine npm install. Two kilobytes, not huge, but you go looking anyway. It's web-vitals — the package half the performance tutorials on the internet tell you to install just so you can console.log three numbers: LCP, CLS, INP.
You open its source expecting something clever. It's mostly PerformanceObserver calls. The exact same API already sitting in your browser, for free, before your bundle even finishes downloading.
So why does nearly everyone reach for the library first? There's exactly one part of this that's genuinely hard to get right by hand — and it's not the part most people worry about.
Largest Contentful Paint is the easy one. The browser already tracks which element painted the biggest visible chunk of content, and it hands you updates as a better candidate shows up:
buffered: true is the part people forget and then can't figure out why their observer logs nothing — it backfills entries that happened before this script ran, which for LCP is usually all of them, since your analytics script rarely loads before the hero image paints.
The browser keeps revising "largest so far" as bigger elements paint in, and stops for good the moment the user scrolls, clicks, or types, or the tab goes hidden — at that point, whatever the last entry said is the final score. entry.element is the actual DOM node, which is a nice touch for debugging ("oh, the LCP element is the hidden carousel slide, not the headline") — except when it's null, which happens if the element has already been removed from the DOM by the time your callback runs. (A separate gotcha hits cross-origin images without a Timing-Allow-Origin header: element still resolves, but renderTime reads as 0, so you have to fall back to loadTime instead — a different field going quiet, not the element.)
Four lines, one footgun, done. This is not the part web-vitals is earning its keep on.
Cumulative Layout Shift reports as a layout-shift entry every time something visible moves without the user asking it to. Each entry has a .value — how much of the viewport shifted, weighted by distance. The obvious thing to do is sum them:
(hadRecentInput is true if the shift happened within 500ms of the user clicking or typing — an accordion that resizes after you click it shouldn't count against you, so the spec excludes it.)
Ship that on a dashboard that stays open all day, with an ad slot that occasionally reflows, and watch clsTotal climb to 3, then 6, then double digits by the afternoon. Nothing on the page ever visibly jumped badly — a few small shifts, hours apart. The number is technically "correct" by your own code, and it is nowhere close to what the real metric reports, because real CLS was never meant to be a lifetime total.
Before you scroll: what do you think the actual rule is? A cap? A time window? A per-minute budget?
It's a session window. Group shifts where the gap since the last one is under 1 second, cap each group at 5 seconds total, and report whichever single window ever added up to the most:
A burst of shifts from one bad ad reflow — all within a second or two of each other — stays grouped into one window and still hurts your score, correctly. But five unrelated shifts scattered across an afternoon never combine into one number, because they never do to a real visitor either: nobody experiences "all the jank from today," they experience one moment where the page jumped under their thumb.
Runs right in your browser — poke at it and watch the concept react live.
The naive move for Interaction to Next Paint is to listen for event entries and log every duration:
Tap a button on a touchscreen and this fires three or four times — pointerdown, pointerup, click, sometimes more — for what the user experienced as one tap. Log each duration as its own interaction and you've just told your dashboard there were four slow interactions where there was one.
The fix is grouping: every event entry belonging to the same physical interaction shares an interactionId, so you group by that and take the worst duration inside each group — one number per real interaction, not one per DOM event:
Even that map isn't the final INP score — the real metric doesn't just report Math.max(...interactions.values()) either. A single freak slow tap (a GC pause at the wrong millisecond) shouldn't define the whole page's score any more than one slow HTTP request should define your p50 latency. The reference implementation tracks a high percentile across the session instead of the single worst value once there have been enough interactions to make a percentile meaningful — which is a few lines of arithmetic you do not want to be debugging for the first time during an incident.
Not the measuring — you just watched that happen in nine lines. It's the three things nobody gets right the first time they hand-roll this: the CLS session-windowing, the INP interaction-grouping-plus-percentile, and the edge cases around bfcache restores and visibility-change timing that silently double-report or under-report a metric if you don't special-case them. That's real, fiddly logic, and web-vitals has had years of bug reports to get it right. If you're sending these numbers to a real-user-monitoring pipeline that someone makes decisions from, use the library for exactly that math.
Here's the honest caveat, and it's narrower than it would have been a year ago: Safari was the last of the three engines to ship largest-contentful-paint and interaction-level event timing (the interactionId grouping above), adding both in Safari 26.2, December 2025. Firefox isn't new to this — it's had largest-contentful-paint since version 122 (early 2024) — but it only picked up interactionId around the same time as Safari, in Firefox 144. With Safari's December 2025 release, all three engines finally agreed on both entry types, which is what pushed web.dev to mark LCP and INP "Baseline: Newly available" at that point. layout-shift didn't make the trip — Cumulative Layout Shift is still Chromium-only; Firefox's tracking bug for it has sat open since 2021, and Safari 26.2 shipped the other two without it. So CLS is the one metric where neither the raw API nor web-vitals can produce a number for a Firefox or Safari visitor — the library's job there is still correctness on the browsers that do report it, not conjuring data from the one that doesn't. Feature-detect before you rely on any of this:
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The library isn't buying you the measurement — the browser was always going to hand you that, Chromium or nothing. It's buying you the arithmetic that's easy to get wrong exactly once, in production, where you won't notice until the dashboard looks wrong for a month. Have you ever shipped a CLS or INP number you'd now bet was quietly miscounted? I'd genuinely like to know in the comments.
- Web Workers in JavaScript: The Complete Guide
- Your Lighthouse Score Is 100. Long Animation Frames Explain the Jank.
- Shadow DOM Needed JavaScript to Exist. Now It Doesn't.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.