The clock
Date.now() has millisecond resolution and can jump backwards when the system clock is adjusted. performance.now() is monotonic and finer-grained — and one run of anything is noise, so measure several and take the median, not the mean.
const started = performance.now();
doTheWork();
const took = performance.now() - started; // sub-millisecond, and monotonic
The metrics, from the browser itself
new PerformanceObserver((list) => {
const last = list.getEntries().at(-1); // LCP can change until the user interacts
console.log("LCP", last.startTime, last.element);
}).observe({ type: "largest-contentful-paint", buffered: true });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) cls += entry.value;
}
}).observe({ type: "layout-shift", buffered: true });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("long task", entry.duration);
}
}).observe({ type: "longtask", buffered: true });
The definitions have edge cases — which shift counts, when LCP stops changing, which interaction is reported — and the library is maintained by the people who write the spec. The code above is worth reading once so the library is not magic.
Where the time went
transferSize === 0 with a non-zero decodedBodySize means it came from the cache — which is how you check that your caching headers are doing what the web course said they would.
const nav = performance.getEntriesByType("navigation")[0];
nav.responseStart - nav.requestStart // time to first byte
nav.domContentLoadedEventEnd // when the DOM was ready
nav.loadEventEnd // everything, including images
for (const r of performance.getEntriesByType("resource")) {
console.log(r.name, Math.round(r.duration), r.transferSize);
}
Marks and measures
Marks and measures show up in the browser's performance panel next to the browser's own events, which turns "the page is slow" into "this function is 400 ms of it" without any guessing.
performance.mark("quote:start");
await priceTheQuote();
performance.mark("quote:end");
performance.measure("quote", "quote:start", "quote:end");
performance.getEntriesByName("quote")[0].duration;
Reporting from real visits
sendBeacon survives the page closing, which fetch often does not. Report on visibilitychange, not on unload — on mobile, unload frequently never fires.
import { onLCP, onCLS, onINP } from "web-vitals";
const queue = [];
const send = () => navigator.sendBeacon("/vitals", JSON.stringify(queue));
onLCP((m) => queue.push(m));
onCLS((m) => queue.push(m));
onINP((m) => queue.push(m));
addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") send(); });