You don't need a paid RUM vendor to start collecting real Core Web Vitals data from actual users. The browser's PerformanceObserver API exposes LCP, CLS, and INP directly, and a script under 100 lines is enough to start logging real numbers instead of waiting on Search Console's 28-day rolling window to notice a problem.
Why Build This Instead of Just Using Search Console
Search Console's Core Web Vitals report is aggregated, delayed, and grouped by URL pattern rather than individual page loads. It's useful for a high-level view, but it can't tell you which specific deploy caused a regression, or correlate a bad session with a specific user agent, connection type, or geography. A lightweight RUM script closes that gap and gives you data within hours instead of weeks.
Step 1: Observe Largest Contentful Paint
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime, lastEntry.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
The buffered: true option is important, it captures entries that occurred before your observer was registered, which matters since LCP can fire before your script finishes loading and executing on a fast page.

Photo by Ron Lach on Pexels
Step 2: Observe Layout Shift
let clsValue = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
console.log('Running CLS:', clsValue);
}).observe({ type: 'layout-shift', buffered: true });
The hadRecentInput check matters here. Layout shifts caused directly by a user's own action, like expanding an accordion, shouldn't count against CLS, and the API flags these so you can exclude them from your running total.
Step 3: Observe Interaction to Next Paint
INP is the trickiest of the three to measure manually, since it requires tracking the worst interaction latency across a full session rather than a single event. A simplified version:
let worstInteraction = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.duration > worstInteraction) {
worstInteraction = entry.duration;
console.log('New worst interaction:', worstInteraction, entry.name);
}
}
}).observe({ type: 'event', durationThreshold: 40, buffered: true });
The durationThreshold filters out interactions fast enough not to matter, since logging every single event would be noisy without adding diagnostic value. Google's own web-vitals JavaScript library implements the full, spec-compliant INP calculation if you want the exact same methodology Search Console uses, rather than this simplified version, and it's worth graduating to once you've validated the concept with a hand-rolled script.
Step 4: Send the Data Somewhere You Can Query It
Console logging is fine for local debugging, but a real setup needs the data sent to an endpoint you control, using navigator.sendBeacon() so the request doesn't get canceled if the user navigates away before it completes.
function sendMetric(name, value, extra = {}) {
const payload = JSON.stringify({ name, value, url: location.href, ...extra });
navigator.sendBeacon('/api/metrics', payload);
}
Wire each observer's callback to call sendMetric instead of console.log, and you have a minimal pipeline flowing into whatever backend or logging service you're already using.
Step 5: Tag Every Metric With a Deploy Version
This is the step that turns raw metrics into an actual diagnostic tool. Include your current deploy's git commit hash or build timestamp as a field on every metric payload. When a regression shows up in your dashboard, you can filter by deploy version and see exactly which release the numbers shifted on, rather than eyeballing a timeline against your deploy log.
const DEPLOY_VERSION = '{{BUILD_COMMIT_HASH}}';
sendMetric('LCP', value, { deploy: DEPLOY_VERSION });
Sampling to Keep Volume Manageable
On high-traffic sites, logging every single page load can generate more data than you need and add unnecessary load to your metrics endpoint. A simple sampling gate, only sending data for a random 10 to 20 percent of sessions, keeps volume reasonable while still giving statistically meaningful trends across enough real sessions to be useful.
web.dev documents the full PerformanceObserver API and the official web-vitals library in detail, and is worth reading before extending this beyond a basic prototype, since there are edge cases around bfcache navigation and page visibility that the full library handles correctly and this simplified version doesn't. MDN has the full browser compatibility tables for each observer type, worth a check if you need to support older browser versions in your audience.
Segmenting by Device and Connection Type
Once basic collection is working, the next useful upgrade is tagging each metric payload with navigator.connection?.effectiveType (where supported) and a rough device classification based on navigator.hardwareConcurrency or screen dimensions. This lets you split your dashboard by real-world segments, seeing whether a regression is universal or concentrated in a specific device or connection tier, which is exactly the kind of detail Search Console's aggregate report can't give you.
sendMetric('LCP', value, {
deploy: DEPLOY_VERSION,
connection: navigator.connection?.effectiveType || 'unknown',
});
This single addition often explains regressions that look confusing in aggregate. A metric that looks flat overall can be hiding a real regression concentrated entirely in the slow-connection segment, which the average smooths out but real affected users definitely notice.
Handling Single Page Applications
For SPAs where navigation doesn't trigger a full page reload, LCP and CLS observers need to be reset and re-registered on each client-side route change, since the browser's native APIs are built around traditional full page navigations by default. Without this, you'll only ever capture metrics for the initial page load and miss every subsequent in-app navigation entirely, which for a SPA-heavy site can mean missing the majority of real user sessions.
function onRouteChange() {
clsValue = 0;
worstInteraction = 0;
}
Call this reset function from your router's navigation lifecycle hook, and re-register fresh observers for the new "page" so metrics stay scoped to the current view rather than accumulating across the entire session.
Why This Matters for the Diagnostic Process
Having your own real-time metrics feed is exactly the tool that turns a multi-day Core Web Vitals investigation into a same-day fix. 137Foundry's guide on diagnosing a Core Web Vitals regression after a deploy covers the broader diagnostic process this kind of monitoring feeds directly into, letting you correlate a metric drop with a specific deploy timestamp instead of waiting weeks for Search Console to catch up.
Where to Take This Next
Once basic collection and deploy tagging are working reliably, the natural next steps are building a simple dashboard to visualize trends over time, setting an alert threshold so a regression pings a Slack channel rather than waiting for someone to check a dashboard manually, and expanding sampling coverage as you get comfortable with the data volume. None of that requires a paid vendor to start, just a willingness to own a bit more infrastructure in exchange for diagnostic speed a vendor dashboard often can't match for your specific deploy workflow.
Start small, validate the numbers against a known-good Lighthouse run to make sure your implementation is sane, and expand from there once you trust what the script is telling you.
A hand-rolled script like this one is a good way to build intuition for how the metrics actually work under the hood, even if you eventually migrate to a full RUM vendor for scale.
Top comments (0)