Measuring

Get real numbers out of a real page, from the API that is already there.

02of 16
≈ 13Min read
5Parts

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.

performance.now(), not Date.now()
const started = performance.now();
doTheWork();
const took = performance.now() - started;    // sub-millisecond, and monotonic

The metrics, from the browser itself

PerformanceObserver, for the three that matter
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 });
Use the web-vitals library rather than this

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.

the navigation and resource timings
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.

your own timings, visible in the profiler
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.

send it when the page goes away, not before
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(); });

Practice