Measuring performance where your users are
Lab scores and field data disagree for structural reasons. What to collect from real sessions, how to sample it, and how to hold a build to a budget.
A Lighthouse score is a simulation on a fixed device and network profile. It is repeatable and useful for catching regressions, and it is not what your users experience — particularly in India, where the device distribution has a long tail of three-to-five-year-old Android handsets and the network varies by an order of magnitude within a single session.
Why the two numbers disagree
| Lab (Lighthouse) | Field (real users) | |
|---|---|---|
| Device | One simulated profile | Whatever your users own, weighted by who visits most |
| Network | A fixed throttle | Handover between cells, congestion, a rickety office connection |
| Cache state | Cold, every run | Mostly warm — repeat visitors dominate on an operations tool |
| Interaction | None, which is why INP cannot be measured | Constant, which is where INP problems live |
| Sample | One run, or five | Thousands, with a tail that matters more than the median |
What to collect, and what to attach to it
A metric with no dimensions attached cannot be acted on: knowing your INP p75 is 480ms tells you nothing about which screen, which device class, or which release. Every measurement should arrive with enough context to be sliced.
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals';
const context = () => ({
route: routePattern(), // /work/[slug], never the resolved URL
release: process.env.NEXT_PUBLIC_RELEASE,
connection: (navigator as any).connection?.effectiveType ?? 'unknown',
memory: (navigator as any).deviceMemory ?? null, // proxy for device class
saveData: (navigator as any).connection?.saveData ?? false,
});
const report = (metric: { name: string; value: number; rating: string }) =>
navigator.sendBeacon('/rum', JSON.stringify({ ...metric, ...context() }));
[onLCP, onINP, onCLS, onTTFB].forEach((fn) => fn(report));- Report the route pattern, not the URL. Per-URL data on a site with case-study slugs fragments into samples too small to read.
- deviceMemory is a crude but effective device-class proxy. Segment by it and the "our site is fast" argument usually ends.
- Keep the release identifier on every beacon. Without it you cannot answer whether last Tuesday made things worse.
- Percentiles only. A mean over a long-tailed distribution is a number with no referent.
INP is where modern React sites lose
Interaction to Next Paint measures the worst interaction latency in a session — the tap that felt stuck. It cannot be measured in the lab, because the lab does not tap. On a heavy client-rendered page, the usual cause is a long task blocking the main thread while the handler waits its turn.
Enforcing a budget in CI
A performance budget that lives in a document is a wish. Put it in the build, fail on regression, and make the failure specific enough that the engineer who caused it knows what to do.
{
"budgets": [
{ "path": "/*", "resourceSizes": [
{ "resourceType": "script", "budget": 180 },
{ "resourceType": "total", "budget": 600 }
]},
{ "path": "/*", "timings": [
{ "metric": "largest-contentful-paint", "budget": 2200 },
{ "metric": "total-blocking-time", "budget": 250 }
]}
]
}