Performance and debugging · 6 / 8
lesson 6

Measuring performance

If you can't measure it, you can't fix it. The Performance API gives you the timestamps the browser uses internally — including Web Vitals.

~ 16 min read·lesson 6 of 8
0 / 8

You changed something to make the page faster. Did it actually help? "It feels snappier" is not data. The page might be 15ms faster, or your machine might just be having a good day.

The browser exposes a small API — performance.now, performance.mark, performance.measure, and PerformanceObserver — that lets you ask honest questions and get honest answers. It's the same API the Performance panel uses internally; the marks you set show up there as labels on the timeline.

This lesson covers the four pieces, then the part most people care about: reading Web Vitals (LCP, INP, CLS) from JavaScript so you can monitor real user performance instead of guessing.

performance.now — high-precision timestamps

Date.now() returns the current time in milliseconds, but only to integer precision and tied to the wall clock — which can jump backward when the OS adjusts the time, breaking any subtraction you do.

performance.now() returns a high-precision timestamp in milliseconds (with fractional digits, typically microsecond precision) measured from the moment the page loaded. It only ever increases, so subtracting two values always gives you a positive duration.

time-it.js
const start = performance.now();
heavyComputation();
const elapsed = performance.now() - start;
console.log("took", elapsed.toFixed(2), "ms");
// took 4.31 ms

This is what console.time uses internally. Use performance.now directly when you want the raw number — to log it elsewhere, send it to an analytics endpoint, or compare against a budget.

The "from page load" zero point matters: a performance.now() value of 1240.5 means "1240.5ms after the page started loading." That's how you measure load-time things relative to the navigation start.

Tip

performance.now() values across two different tabs are not comparable — each tab's clock starts at its own page-load. For cross-tab timing, use Date.now() and accept the lower precision.

Marks and measures — name your moments

For anything more elaborate than a single before/after, raw performance.now() calls turn into a bowl of variables. performance.mark and performance.measure give you a higher-level vocabulary: name a moment, then later ask for the duration between two named moments.

marks.js
performance.mark("checkout-clicked");

await fetchCart();
performance.mark("cart-loaded");

await calculateTax();
performance.mark("tax-calculated");

performance.measure("cart-to-tax", "cart-loaded", "tax-calculated");
performance.measure("full-checkout", "checkout-clicked", "tax-calculated");

console.table(performance.getEntriesByType("measure"));

Two upgrades over hand-rolled timestamps:

  • The names persist. Anywhere in the code you can later ask "what was the time from checkout-clicked to tax-calculated?" You don't have to plumb timestamp variables through.
  • The marks show up in devtools. Open the Performance panel, record a session that includes these marks, and they appear as labelled flags on the timeline. Suddenly you can see exactly which bar in the flamegraph corresponds to which mark.

getEntriesByType("measure") returns an array of every measure with its name, startTime, and duration. console.table (lesson 3) renders the array as a sortable table — perfect for spotting which measure was the slowest.

clickedcart-loadedtax-donecart-to-taxfull-checkout
Marks are points in time. Measures are durations between two marks.

PerformanceObserver — react to entries as they happen

getEntriesByType is pull-based — you call it and get whatever's in the buffer. For things you didn't trigger yourself (image loads, font fetches, layout shifts, the browser's own measurements), pull-based is awkward. You'd have to poll.

PerformanceObserver is push-based. You hand it a callback, tell it which entry types to watch, and it fires whenever a new matching entry appears.

observer.js
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
  console.log(entry.name, entry.duration.toFixed(2), "ms");
}
});

observer.observe({ entryTypes: ["resource", "measure"] });

This logs every resource fetch and every performance.measure call, the moment it happens. The browser populates the entries; you just react.

The entry types you can observe include resource (every network fetch the browser made), measure (your own measures), navigation (the document load itself), paint (when the browser first painted, when content first appeared), largest-contentful-paint, layout-shift, first-input, and a few more. The Web Vitals trio uses three of those.

Web Vitals from JS

Three Web Vitals matter most. They're the metrics Google uses to grade real-user performance, and they're available from the same PerformanceObserver API.

LCP — Largest Contentful Paint. When the largest visible element (usually an image or hero text) finished rendering. A proxy for "when did the page look done?". Target: under 2.5 seconds.

lcp.js
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
  console.log("LCP:", entry.startTime.toFixed(0), "ms", entry.element);
}
}).observe({ type: "largest-contentful-paint", buffered: true });

The buffered: true is important — it asks the browser to give you any LCP entries that already happened before the observer was created. Without it, you'd miss the LCP if your script registered the observer too late.

The entry includes the actual element that won — useful when you want to know which image was the LCP element so you can preload it.

INP — Interaction to Next Paint. When the user clicked, tapped, or pressed a key, how long until the page next painted? It measures the slowest interaction over the page's life. Target: under 200ms.

inp.js
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
  console.log("interaction:", entry.name, entry.duration.toFixed(0), "ms");
}
}).observe({ type: "event", buffered: true, durationThreshold: 16 });

The durationThreshold: 16 tells the browser to skip events that finished within one frame — there's no point reporting interactions that were already fast.

INP-as-a-single-number is a calculation across all the entries; libraries (web-vitals is the canonical one) maintain that aggregate for you. For ad-hoc inspection in devtools, the per-event duration is the useful number.

CLS — Cumulative Layout Shift. A score (0 = perfect, 1 = bad) of how much things on the page moved unexpectedly during the user's session. Each shift contributes its size and distance.

cls.js
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
  if (!entry.hadRecentInput) {
    cls += entry.value;
    console.log("CLS so far:", cls.toFixed(3));
  }
}
}).observe({ type: "layout-shift", buffered: true });

The hadRecentInput check filters out shifts the user caused (clicking a "Show more" button is expected movement, not surprise movement). The score accumulates as the page lives.

For production monitoring you almost certainly want the official web-vitals library (3KB, gzipped). It handles edge cases — the page being hidden, BFCache restores, attribution data — that hand-rolled code easily misses. But knowing the underlying API matters: the library is a thin wrapper around what you've just seen.

Watch out

Web Vitals measured in your dev environment are essentially meaningless. You're on a fast machine, fast network, with no other tabs and no third-party scripts. The numbers that matter are the ones from real users on real networks — which means measuring in production and shipping the data somewhere.

A note on Lighthouse

Lighthouse is a tool that runs in devtools' Lighthouse panel (or as a CLI). It loads your page in a controlled environment, measures the Web Vitals, and gives you a 0–100 score plus a list of suggestions.

It's a wrapper over what this lesson taught — PerformanceObserver watching the same entry types, run in a known environment for repeatability. It's useful as a smoke test ("did my change make the score worse?") and useless as the only number you watch ("our Lighthouse score is 95!"). The thing that matters is what real users experience, which Lighthouse can only approximate.

Use Lighthouse in CI as a guard against regressions. Use real-user measurements (the web-vitals library reporting to your analytics endpoint) for the truth.

check your understanding
You're benchmarking a function. You wrap it in Date.now() calls. The reported time is sometimes 0ms. What's the explanation?
check your understanding
You add a PerformanceObserver for largest-contentful-paint at the bottom of your bundle. The observer fires zero times. The page does have a clear LCP element. What's wrong?
check your understanding
You time a function with performance.mark and performance.measure, then open the Performance panel and record a session. Where do your marks show up?
check your understanding
Your local Lighthouse score is 98. Real users on your analytics dashboard report a Web Vitals 'pass' rate of 60%. Which set of numbers should drive your work?
← prevnext lesson →
KeepLearningcertificate
for completing
Performance and debugging
0 of 8 read