Your Lighthouse score is green, yet Search Console says your URLs "need improvement". Both can be true, because they measure different things. Lighthouse is one lab run on a simulated device, while Google judges your pages on field data from real visits, evaluated at the 75th percentile (p75), as this Core Web Vitals guide explains.
This guide builds the smallest useful real-user monitoring (RUM) setup: the web-vitals library in the browser, a beacon, a tiny Node endpoint, and a p75 calculation you can compare with Search Console and CrUX. The thresholds are the usual ones: LCP under 2.5s, INP under 200ms, CLS under 0.1.
Status of the code: none of the snippets below has been executed. Treat them as a starting point, run them locally, and fix anything that differs in your environment. The Core Web Vitals guide does not name a web-vitals version, so check the package page on npm and the project README for the current major version and API before you pin it.
Why Lighthouse and Search Console disagree
A lab run uses one machine, one network profile, and no real user behaviour. Field data covers your actual visitors: their mid-range phones, their flaky connections, and the pages they really click through. The Core Web Vitals guide puts it plainly: a perfect lab score with poor field data will not pass.
The p75 detail matters just as much. Improving your median does not help if the slowest quarter of visits still fails. Your own setup must therefore report p75, not an average.
INP adds a second gap. It measures responsiveness across a whole visit, so a lab load with no clicks cannot reveal a slow menu handler or a heavy third-party widget. Only real interactions expose those.
The plan in five steps
- Install
web-vitalsand confirm its current API. - Send LCP, INP and CLS from the browser with a beacon.
- Run a small Node endpoint that stores each metric.
- Compute p75 per metric, optionally per device and per page.
- Verify the numbers and cross-check them against Search Console and CrUX.
Step 1: Install and confirm the version
Run npm install web-vitals in your front-end project. npm records the resolved version in package.json. Commit the lockfile so every build uses the same one.
The code below uses onLCP, onINP and onCLS. Open the README for the version you installed and confirm that these functions exist and that the metric object still carries name, value, rating, id and navigationType. If you load the library from a CDN instead of a bundler, pin an exact version in the URL rather than using a floating tag.
Step 2: Send metrics from the browser
Each callback fires with a metric object. Forward it with navigator.sendBeacon, which is built to survive the page being closed or backgrounded. That matters because CLS and INP are only final late in the visit.
// vitals.js (UNEXECUTED example)
import { onLCP, onINP, onCLS } from "web-vitals";
const ENDPOINT = "/v1/m";
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
path: location.pathname,
device: window.matchMedia("(max-width: 767px)").matches ? "mobile" : "desktop"
});
const queued = navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body);
if (!queued) {
fetch(ENDPOINT, { method: "POST", body, keepalive: true });
}
}
onLCP(send);
onINP(send);
onCLS(send);
Two choices here are deliberate. A string body goes out as text/plain, which avoids a CORS preflight when the endpoint is same-origin or proxied. The device field is a crude viewport check. It separates phone-sized from desktop-sized visits but is not a real device classifier. The endpoint path is deliberately bland; the failure section below explains why.
The same metric can be reported more than once per page view, for example when the tab is hidden and shown again. The id field lets the server keep only the latest value per page view.
Step 3: Store metrics with a small Node endpoint
This server uses only Node built-ins, so there is nothing else to pin. It validates input, appends each row to a file, and keeps rows in memory keyed by id and metric name. It assumes a recent Node LTS release; confirm that yours supports Object.hasOwn. We cover related ground in see also: build a python seo audit script: step-by-step guide.
// server.mjs (UNEXECUTED example)
import http from "node:http";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
const FILE = "vitals.ndjson";
const LIMITS = { LCP: 2500, INP: 200, CLS: 0.1 };
const store = new Map();
const keep = (row) => store.set(`${row.id}:${row.name}`, row);
if (existsSync(FILE)) {
for (const line of readFileSync(FILE, "utf8").split("\n")) {
if (line) keep(JSON.parse(line));
}
}
function p75(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.max(0, Math.ceil(sorted.length * 0.75) - 1)];
}
function summarise(filter) {
const out = {};
for (const name of Object.keys(LIMITS)) {
const values = [...store.values()]
.filter((r) => r.name === name)
.filter((r) => !filter.device || r.device === filter.device)
.filter((r) => !filter.path || r.path === filter.path)
.map((r) => r.value);
if (!values.length) { out[name] = { samples: 0 }; continue; }
const v = p75(values);
out[name] = {
samples: values.length,
p75: name === "CLS" ? Number(v.toFixed(3)) : Math.round(v),
threshold: LIMITS[name],
status: v <= LIMITS[name] ? "good" : "needs work"
};
}
return out;
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/v1/m") {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 4096) req.destroy();
});
req.on("end", () => {
try {
const m = JSON.parse(raw);
if (!Object.hasOwn(LIMITS, m.name) || !Number.isFinite(m.value) || typeof m.id !== "string") {
throw new Error("invalid");
}
const row = {
id: m.id,
name: m.name,
value: m.value,
rating: m.rating,
path: String(m.path).slice(0, 200),
device: m.device === "mobile" ? "mobile" : "desktop"
};
keep(row);
appendFileSync(FILE, JSON.stringify(row) + "\n");
res.writeHead(204).end();
} catch {
res.writeHead(400).end();
}
});
return;
}
if (req.method === "GET" && url.pathname === "/summary") {
const filter = {
device: url.searchParams.get("device"),
path: url.searchParams.get("path")
};
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(summarise(filter), null, 2));
return;
}
res.writeHead(404).end();
});
server.listen(3000);
The p75 function uses the nearest-rank method: sort the values, then take the one at position ceil(0.75 x n). Other percentile methods interpolate and can differ slightly on small samples.
Before you deploy, protect /summary behind authentication or a firewall. Also serve /v1/m through your own domain (a reverse proxy rule works) so the browser treats it as first-party.
The payload carries no cookies, user IDs or IP addresses. Whether you need consent for even this much depends on your jurisdiction, so check before launching.
Step 4: Check that the pipeline works
Start the server with node server.mjs. Before wiring up the browser, post a fake row:
curl -X POST http://localhost:3000/v1/m -d '{"name":"LCP","value":2100,"id":"v1-a","rating":"good","path":"/","device":"mobile"}'
Then open your page with the snippet bundled in. Switch tabs or navigate away to flush CLS and INP, and look at the network log. You should see POST requests to /v1/m. A real payload looks like this (illustrative values, not measurements):
{"name":"INP","value":168,"rating":"good","id":"1727860000000-8431","navigationType":"navigate","path":"/blog/post","device":"mobile"}
Once a few visits have landed, request /summary?device=mobile. Expect this shape, again with made-up numbers:
{
"LCP": { "samples": 212, "p75": 2740, "threshold": 2500, "status": "needs work" },
"INP": { "samples": 96, "p75": 184, "threshold": 200, "status": "good" },
"CLS": { "samples": 212, "p75": 0.04, "threshold": 0.1, "status": "good" }
}
The INP sample count is lower than LCP. Many visits never click or tap anything, so INP has nothing to report for them. That is expected behaviour, not a bug.
Step 5: Cross-check against Search Console and CrUX
Open the Core Web Vitals report in Search Console and compare its mobile and desktop groups with your device filter. Expect the numbers to be close, not identical. Search Console and CrUX draw on a different population (Chrome users who contribute data) and a longer reporting window than your fresh endpoint. Your own beacon also sees browsers that CrUX does not.
For a per-URL view, recent Chrome DevTools versions can show live local metrics in the Performance panel and compare them with CrUX field data for the page. Menu names change between releases, so confirm the exact path in your Chrome version.
If your p75 says "needs work" and Search Console agrees, you have a real problem and a way to watch it improve. If the two disagree wildly, work through the checklist below.
Failure cases and how to diagnose them
Some browsers send nothing
The library only calls your callback when the browser exposes the underlying measurement. Support differs by browser and changes over time, and the README for your installed version lists what is supported. To diagnose it, group rows by user agent or compare the sample count per metric. A missing metric in one browser is silence, not a zero.
Ad-blockers drop the beacon
Blocklists often match URLs containing words like "analytics", "track" or "collect". Send to your own domain and use a neutral path. Then compare your total LCP samples with server-side page view counts. A large shortfall points to blocked beacons, and because blocked visits never appear in your data, you cannot easily correct for them.
Too few samples make p75 jumpy
With 20 samples, p75 is effectively the fifth-highest value, so one slow session can flip a verdict. Always display samples next to p75. As a rule of thumb (an inference, not a published rule), treat any segment with fewer than a few hundred samples as a hint, not a result. Before you act, widen the filter by dropping path, or collect for longer.
Your own visits pollute the data
Your fast laptop on office Wi-Fi pulls the numbers down. Skip sending from your own sessions, for instance with a flag stored in localStorage, or exclude your IP at the proxy.
Rows arrive duplicated or late
The same page view can report a metric more than once, and a browser can kill a tab before anything is sent. The id dedupe handles the first problem. The second leaves small gaps, which you should accept rather than chase.
What to expect next
Once enough normal traffic has landed, you will see which pages and device classes sit above the 2.5s, 200ms and 0.1 lines. Pick the worst failing metric on your busiest page and fix that first, using the LCP, INP and CLS remedies in the Core Web Vitals guide.
Then let the endpoint collect fresh data and watch p75 move, which is the check Lighthouse alone cannot give you. Search Console reports over a longer window, so judge early progress by your endpoint and confirm it there later.



