completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Measuring performance

Learn to profile JavaScript bottlenecks with Performance API marks, DevTools flame charts, RUM data, and safer benchmarks.

By the end, you can
  • 01
    Measure the right thingUse percentiles, your own marks and measures, page-loading timings, and observers before you change any code.
  • 02
    Read a recording instead of guessingFind your way around a Chrome DevTools recording: the Main track, flame charts, Bottom-up, Call tree, Event log, and Insights.
  • 03
    Compare lab tests, real users, and benchmarksTell apart a Lighthouse test on one device, data from real users (CrUX and RUM), and a careful speed test with a warm-up and a median.

Measure before you make it fast

A page feels slow. Why? There are many possible reasons:

  • The network is slow, so files arrive late.
  • The browser spends a long time reading a big script.
  • One piece of JavaScript runs for so long that clicks have to wait.
  • The browser has to work out the page layout again and again.
  • A script from another company, such as an ad or a chat box, is busy.

If you guess, you may fix the wrong thing. You spend time, and the page may even get worse. So first you measure.

Definition

Measuring performance means collecting timings you can trust before you change any code. The timings come from four places: the browser's own timing tools, the browser's developer tools (DevTools), real users, and small speed tests that you write with care.

This lesson is only about measuring. You will learn:

  • User Timing: putting your own named timestamps in the page.
  • DevTools recordings and flame charts: pictures of where the time went.
  • Real user monitoring (RUM): timings sent from your real users' browsers.
  • Speed tests (benchmarks) and the mistakes that make them lie.

The next lesson, Keeping the main thread responsive, uses these measurements to fix slow clicks. Later lessons cover debounce and throttle, rendering speed, loading speed, and memory leaks.

Real-life analogyA doctor, a lab test, and a fitness tracker

You tell a doctor, "I feel tired." A good doctor does not hand you random medicine. First she checks your pulse. Then she orders a test and reads the result. Only then does she choose a treatment. Speeding up a page works the same way: measure first, then fix the slow part you actually found.

In real life: You tell the doctor something is wrong
In JavaScript: A user says the page feels slow, or a number looks bad
In real life: The doctor checks your pulse before giving medicine
In JavaScript: You measure with marks, recordings, and real-user data before changing code
In real life: A blood test in a lab, done the same way every time
In JavaScript: A DevTools recording or a Lighthouse test on one test device
In real life: A fitness tracker you wear all day, every day
In JavaScript: Real user monitoring: timings from many real phones and computers

These earlier lessons help here: Debugging in the browser, Advanced debugging, Observers, Page lifecycle, and the event loop.

Percentiles tell you more than the average

MEASURE FIRST

Say you timed five requests. Most were quick, but one took a whole second. The average mixes them all into one number. That number can look fine while some users wait a long time.

A percentile asks a different question: "What time did 75% of the requests stay under?" We write it as p75. In the same way:

  • p50 is the middle value. Half the requests were faster.
  • p75 is the time 75% of requests stayed under. Teams often use it as their target.
  • p95 shows the slow end: the unlucky 5% of requests were slower than this.
The average and three percentiles of five request timesPop out in the code editor (opens in a new tab)JavaScript
const responseTimes = [120, 80, 1000, 160, 240]; function percentile(values, p) {  if (values.length === 0) throw new RangeError("percentile needs values");  const sorted = [...values].sort((a, b) => a - b);  const rank = Math.ceil((p / 100) * sorted.length);  const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));  return sorted[index];} const average = responseTimes.reduce((total, value) => total + value, 0) / responseTimes.length;console.log("average", average);console.log("p50", percentile(responseTimes, 50));console.log("p75", percentile(responseTimes, 75));console.log("p95", percentile(responseTimes, 95));

Here is the percentile function, line by line:

  • Line 5 sorts a copy of the list from fastest to slowest. The original list stays as it was.
  • Line 6 works out the rank: the position in the sorted list, counting from 1. For p75 of 5 values, that is 4.
  • Line 7 turns the rank into an array index by taking away 1, because arrays count from 0. It also keeps the index inside the list.
  • Line 8 returns the value at that index.

The average is 320 ms. But p75 is 240 ms and p95 is 1000 ms. The percentiles show the one slow request clearly.

The five response times sorted from fastest to slowest: 80, 120, 160, 240, 1000. p50 points at 160, p75 points at 240, and p95 points at 1000. The average is 320.sorted times (ms)801201602401000p50p75p95average = 320
Sort the times, then count along. p75 is 240 and p95 is 1000. The average, 320, is not any real request.
Step through p50, p75, and p95
Step 0 of 6Ready
Your turn: follow the blue line

Step through the percentile function. Watch how the one slow request becomes p95.

Running in
  1. script
Next: line 1
 function percentile(values, p) {  if (values.length === 0) throw new RangeError("percentile needs values");  const sorted = [...values].sort((a, b) => a - b);  const rank = Math.ceil((p / 100) * sorted.length);  const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));  return sorted[index];} const average = responseTimes.reduce((total, value) => total + value, 0) / responseTimes.length;console.log("average", average);console.log("p50", percentile(responseTimes, 50));console.log("p75", percentile(responseTimes, 75));console.log("p95", percentile(responseTimes, 95));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose which percentile to step through

Picking another percentile starts again from the top, with the same five request times.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
How fast a page feels is not one number

A page can feel fast if the useful part shows up quickly, even while other work goes on in the background. A page can also feel slow when most requests are quick but one important click gets stuck. Always connect a number to something the user sees or does.

The Performance API: a stopwatch and named timestamps

USER TIMING

The browser gives you a built-in object called performance. Its performance.now() method is a stopwatch. It returns the number of milliseconds since the page started loading. Two things make it good for timing:

  • It only ever moves forward. It never jumps back, even if someone changes the computer's clock. (The word for this is monotonic.)
  • It shows parts of a millisecond, such as 12.4.

For safety, browsers round these numbers a little. How much depends on the browser. So don't treat a very short time as exact, and don't compare it across different devices.

Date.now() can give a negative timePop out in the code editor (opens in a new tab)JavaScript
let wallClock = 1000;const DateNow = () => wallClock; const started = DateNow();wallClock -= 50; // someone sets the clock backconst elapsed = DateNow() - started; console.log(elapsed);console.log("performance.now is monotonic instead");

Date.now() reads the computer's clock, the one that shows the date and time. That clock can be changed by the user, the operating system, or a server. This code fakes such a change so you can see it:

  • Lines 1 and 2 make a pretend clock that starts at 1000.
  • Line 4 saves the start time, 1000.
  • Line 5 moves the clock back by 50, as if someone changed it.
  • Line 6 takes away the start time. The result is -50: a negative time, which makes no sense.

performance.now() can't go backward, so use it to time things.

Time a small sum with performance.now()Pop out in the code editor (opens in a new tab)JavaScript
const started = performance.now();
const total = [12, 18, 25].reduce((sum, price) => sum + price, 0);
const elapsed = performance.now() - started;

console.log("total", total);
console.log("elapsed is a finite duration", Number.isFinite(elapsed) && elapsed >= 0);

Line 1 reads the stopwatch before the work. Line 2 adds up three prices. Line 3 reads the stopwatch again and takes away the start. The result is how long the sum took. The exact number changes on every device, so the last line only checks that it is a real number and not negative.

User Timing lets you put your own names on moments. It has two methods:

  • performance.mark(name) saves a named moment, called a mark.
  • performance.measure(name, ...) saves the time between two marks, called a measure.

The browser keeps each mark and measure in a list called the performance timeline. Each item in it is an entry.

Mark the start and end of a cart stepPop out in the code editor (opens in a new tab)JavaScript
performance.mark("cart:start");
const cartCount = ["tea", "bread"].length;
performance.mark("cart:end");
performance.measure("cart:prepare", { start: "cart:start", end: "cart:end" });
console.log("cart items", cartCount);
console.log("measure exists", performance.getEntriesByName("cart:prepare", "measure").length === 1);
performance.clearMarks("cart:start");
performance.clearMarks("cart:end");
performance.clearMeasures("cart:prepare");
  • Line 1 saves a mark called cart:start.
  • Line 2 is the work: count the items in the cart.
  • Line 3 saves a mark called cart:end.
  • Line 4 saves a measure called cart:prepare, from the start mark to the end mark.
  • Lines 5 and 6 print the item count, 2, and check that exactly one measure with that name exists.
  • Lines 7 to 9 delete these marks and this measure, so a later run can't mix up old entries with new ones.
Line 1 places a mark called cart:start. Line 2 counts the cart items. Line 3 places a mark called cart:end. Line 4 makes a measure called cart:prepare that covers the time between the two marks.line 1line 2line 3markcart:startcount itemsmarkcart:endmeasure: cart:prepareline 4: the time between the marks
A mark is a named moment. A measure is the time between two marks, with its own name.
A helper that marks, measures, and cleans upPop out in the code editor (opens in a new tab)JavaScript
function measure(label, fn) {  const start = label + ":start";  const end = label + ":end";  performance.mark(start, { detail: { label, phase: "start" } });  const result = fn();  performance.mark(end, { detail: { label, phase: "end" } });  performance.measure(label, { start, end, detail: { label, result } });  performance.clearMarks(start);  performance.clearMarks(end);  return { result, entries: performance.getEntriesByName(label, "measure") };} const report = measure("hydrate-card", () => "ready");console.log(report.result);console.log(report.entries.at(-1).name);console.log(report.entries.at(-1).duration >= 0);performance.clearMeasures("hydrate-card");

This measure helper times any function you give it. A mark or measure can also carry a detail object: extra information you choose, such as a label. Line by line:

  • Line 4 saves the start mark, with a detail object.
  • Line 5 runs the work and keeps its result.
  • Line 6 saves the end mark.
  • Line 7 saves a measure between the two marks.
  • Lines 8 and 9 delete the two marks. They are not needed any more.
  • Line 10 returns the work's result and the measure entries. getEntriesByName finds entries by their name.

To get every measure, whatever its name, use getEntriesByType("measure").

Step through the measure helper: mark, measure, delete
Step 0 of 7Ready
Your turn: follow the blue line

Step through the measure helper. It saves two marks and a measure, then deletes the two marks.

Running in
  1. script
Next: line 13
function measure(label, fn) {  const start = label + ":start";  const end = label + ":end";  performance.mark(start, { detail: { label, phase: "start" } });  const result = fn();  performance.mark(end, { detail: { label, phase: "end" } });  performance.measure(label, { start, end, detail: { label, result } });  performance.clearMarks(start);  performance.clearMarks(end);  return { result, entries: performance.getEntriesByName(label, "measure") };} console.log(report.result);console.log(report.entries.at(-1).name);console.log(report.entries.at(-1).duration >= 0);performance.clearMeasures("hydrate-card");
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Time a task in your browser
The measure helper this panel runsPop out in the code editor (opens in a new tab)JavaScript
function measure(label, fn) {  const start = label + ":start";  const end = label + ":end";  performance.mark(start, { detail: { label, phase: "start" } });  const result = fn();  performance.mark(end, { detail: { label, phase: "end" } });  performance.measure(label, { start, end, detail: { label, result } });  performance.clearMarks(start);  performance.clearMarks(end);  return { result, entries: performance.getEntriesByName(label, "measure") };} const report = measure("hydrate-card", () => "ready");console.log(report.result);console.log(report.entries.at(-1).name);console.log(report.entries.at(-1).duration >= 0);performance.clearMeasures("hydrate-card");
Saved in your browsernot run

Waiting for your browser to run the first task.

Try it yourself

Run a task to save two marks and a measure with your browser's Performance API.

This panel uses the same measure helper as the lesson. The times change with your device and what else the browser is doing.
console.time() is a quick check while you workPop out in the code editor (opens in a new tab)JavaScript
console.time("filter visible products");["keyboard", "mouse", "monitor"].filter(item => item.includes("o"));console.timeEnd("filter visible products");

console.time(label) starts a timer, and console.timeEnd(label) prints how long it ran. The two labels must match. It is handy for a quick check, but:

  • It does not save a mark or measure entry.
  • It does not show up as a named block in a DevTools recording.
  • It is easy to forget in the code.

For timings you want to keep, use marks and measures instead.

PerformanceObserver: get told about new entries

OBSERVE

A PerformanceObserver calls your function each time the browser adds a new entry of a type you asked for. Some entries are made before your code even runs, such as the time of the first paint. The option buffered: true asks for those earlier entries too.

Check which entry types the browser reports, then watch for marksPop out in the code editor (opens in a new tab)JavaScript
const types = [  "mark",  "measure",  "longtask",  "long-animation-frame",  "navigation",  "resource",  "paint",  "largest-contentful-paint",  "event",]; const supportedTypes = PerformanceObserver.supportedEntryTypes ?? [];console.log(types.filter(type => supportedTypes.includes(type)).join(", ")); if (supportedTypes.includes("mark")) {  const observer = new PerformanceObserver(list => {    for (const entry of list.getEntries()) {      console.log(entry.entryType, entry.name);    }    observer.disconnect();  });   observer.observe({ type: "mark", buffered: true });  performance.mark("search-start", { detail: { route: "/products" } });}
  • Lines 1 to 11 list some entry types this lesson talks about.
  • Line 13 reads PerformanceObserver.supportedEntryTypes: the types this browser can report. Line 14 prints the ones on our list.
  • Line 16 only goes on if the browser supports marks.
  • Lines 17 to 22 make the observer. Its function prints each new entry, then line 21 stops the observer.
  • Line 24 starts watching for marks, including earlier ones.
  • Line 25 saves a mark, so the observer has something to report.

Different browsers report different entry types. So check supportedEntryTypes before you watch for a newer type, such as long-animation-frame. This lesson only names types like long tasks, long animation frames, LCP, and event timing. The main-thread and loading-speed lessons use them in detail.

marknot reported here
measurenot reported here
longtasknot reported here
long-animation-framenot reported here
navigationnot reported here
resourcenot reported here
paintnot reported here
largest-contentful-paintnot reported here
eventnot reported here

The browser also saves one navigation entry about how the page itself loaded. It holds many timestamps. Taking one from another tells you how long each step took.

Split page loading into stepsPop out in the code editor (opens in a new tab)JavaScript
const nav = performance.getEntriesByType("navigation")[0]; if (nav) {  const breakdown = {    dns: nav.domainLookupEnd - nav.domainLookupStart,    tcp: nav.connectEnd - nav.connectStart,    ttfb: nav.responseStart - nav.requestStart,    domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,    load: nav.loadEventEnd - nav.startTime,  };  console.log(JSON.stringify(breakdown));}

Line 1 gets the navigation entry. Lines 5 to 9 each take one timestamp from another. The table below explains each step. Read one row per step: its name, the sum that gives it, and what the time means.

Steps of page loading in the navigation entry
StepHow to work it outWhat the time means
DNSdomainLookupEnd - domainLookupStartHow long it took to turn the site name into a server address.
TCPconnectEnd - connectStartHow long it took to connect to the server. On HTTPS this includes the secure handshake.
TTFBresponseStart - requestStartTime to first byte: from sending the request to getting the first piece of the answer.
DOMContentLoadeddomContentLoadedEventEnd - startTimeWhen the HTML was read and the defer scripts had run.
loadloadEventEnd - startTimeWhen images and other files had loaded and the load event had finished.

The Performance panel in Chrome DevTools

TRACE

DevTools answers the question "What happened while I clicked?" Here is how to use it:

  1. Open DevTools and choose the Performance panel.
  2. The Live metrics screen gives a quick look at the page's speed numbers.
  3. To see an interaction, press record, do the click, and stop. To see page loading, use record and reload.
  4. In Capture settings, you can slow down the CPU and the network on purpose, to act like a cheaper phone. DevTools may suggest settings that match your real users.

The result is a recording (also called a trace): a timeline of everything the browser did. The table below lists its main parts. Each row names a part and says when to use it.

Main parts of the Chrome Performance panel
PartWhat it shows you
Live metrics screenA quick look at the page's speed numbers on your computer. It can also show real-user numbers from CrUX, your clicks, and things that jumped around.
Main trackThe jobs (tasks) the main thread ran. A task over 50 ms is a long task and gets a red triangle.
Bottom-up tabThe functions that did the most work themselves, so you see self time first.
Call tree tabCalls from the top down, so you see which chain of calls took the most time in total.
Event log tabEverything that happened, in exact order.
Insights sidebarLikely problems that DevTools found for you, like the ones Lighthouse reports.

The main thread is where the browser runs your JavaScript and draws the page, one job at a time. The Main track shows those jobs, called tasks. A task that runs longer than 50 ms is a long task; it gets a red triangle. Your marks and measures show up in the Timings track. That way your own names line up with everything else the browser did: running scripts, layout, painting, network requests, and screenshots.

The tabs at the bottom each answer a different question:

  • Bottom-up: "Which functions did the most work themselves?"
  • Call tree: "Which chain of calls took the most time in total?"
  • Event log: "What happened, in exact order?"

The Insights sidebar points out likely problems for you, such as what made the page's main content show up late.

Recording checklist
  1. Pick one slow action you can repeat.
  2. Record it with the CPU and network slowed down to match real users.
  3. Look for one long task, or one wide block in the flame chart.
  4. Check the same problem in a second recording, or in real-user data, before you change code.

Flame charts: across is time, down is calls

CALL STACKS

A flame chart draws each function call as a bar. You read it like this:

  • Left to right is time. A bar starts when its function started running.
  • The width of a bar is how long the call took, including the functions it called. This is its total time.
  • A bar sits under the function that called it. Each row down is one call deeper.

A function's self time is the time it spent on its own work. It is the part of its bar that has no bars under it.

A flame chart of checkout, 25 milliseconds wide. Under checkout are parseCart for 4 ms, priceItems for 14 ms, and renderSummary for 7 ms. Under priceItems are readPrices for 5 ms and applyDiscounts for 9 ms.checkoutparseCartpriceItemsrenderSummaryreadPricesapplyDiscounts0 ms25 mstime
A wider bar took longer. A bar sits under the function that called it, so checkout covers everything.
Read a tiny flame chart
The checkout calls in the chartPop out in the code editor (opens in a new tab)JavaScript
function profileCheckout() {  return profile("checkout", () => {    parseCart();    priceItems();    renderSummary();  });} function priceItems() {  readPrices();  applyDiscounts();}
Flame chart25 ms total
Try it yourself

applyDiscounts: 9 ms in total, 9 ms of its own work (self time). The widest bar is checkout. The most self time is in applyDiscounts.

The times in this chart are fixed, so you can practise before opening DevTools: across is time, width is how long, and each row down is one call deeper.

Try the small chart first, before you open a big real one. checkout is the widest bar, because it calls everything else. Under it, priceItems calls readPrices and applyDiscounts. applyDiscounts has the most self time: 9 ms of its own work.

A real DevTools chart is built from samples: the browser checks what is running many times a second. So very short functions may show up in one recording and not in the next.

Real user monitoring: timings from real users

RUM

There are two kinds of timing data:

  • Lab data comes from a test you run yourself, on one device, the same way each time. Lighthouse (a page-checking tool built into Chrome), WebPageTest, Playwright, and DevTools recordings all give lab data.
  • Field data comes from real users, on their own phones, computers, and networks.

Field data comes from two places:

  • The Chrome User Experience Report (CrUX) is free field data that Google collects from Chrome users, for sites with enough visitors.
  • Real user monitoring (RUM) is field data that your own page sends to your own server. You can split it by page, device type, app version, or network, as long as it keeps users private.

Google's Core Web Vitals are three field numbers about how a page feels: how fast it loads, how fast it reacts to a click, and how still it stays. Each one is checked at p75 of visits, with phones and computers counted separately.

Field data picks the problem; a lab test looks for the causePop out in the code editor (opens in a new tab)JavaScript
const fieldFinding = "mobile checkout INP needs investigation";
const labQuestion = "which task delayed the checkout click?";

console.log("prioritize from field data:", fieldFinding);
console.log("investigate with a lab trace:", labQuestion);

These two strings are a pretend plan, not real data. Line 1 is what field data might tell you: checkout on phones reacts slowly. Line 2 is the question you then answer with a lab recording: which task held up the click?

Step 1, field data from real users: mobile checkout INP needs investigation. An arrow leads to step 2, a lab trace: which task delayed the checkout click?1. find what is slowfield data (RUM)mobile checkout INPneeds investigation2. find whylab tracewhich task delayedthe checkout click?
Real users show you what is slow. A lab recording on a test device helps you find why.

The table below lists the three Core Web Vitals. Each row gives the name, what it measures, the time that counts as good, and a common reason it goes wrong.

The three Core Web Vitals and their good values
NameWhat it measuresGood ifA common reason it is bad
LCPLargest Contentful Paint: how fast the main content shows up.2.5 s or lessThe biggest picture or text block on screen shows up late.
INPInteraction to Next Paint: how fast the page reacts to a click or key press.200 ms or lessThe click has to wait while the main thread is busy.
CLSCumulative Layout Shift: how much things jump around.0.1 or lessThings move after they first appear, such as text pushed down by a late image.

Lighthouse can't measure INP, because nobody clicks during its test. Instead it reports Total Blocking Time: how long the main thread was too busy to react. It is a useful hint, but it is not the same number as INP.

Check a made-up p75 against the INP targetPop out in the code editor (opens in a new tab)JavaScript
const visitINP = [120, 160, 180, 260];
const sorted = [...visitINP].sort((a, b) => a - b);
const rank = Math.ceil(0.75 * sorted.length);
const p75 = sorted[rank - 1];

console.log("p75 meets 200 ms target", p75 <= 200);

The four values on line 1 are made up, not real data. Line 2 sorts them. Line 3 finds the p75 rank, 3. Line 4 reads that value, 180. The last line prints whether 180 is within the 200 ms target: true. A real report needs many more visits.

Lab data, field data, or a speed test?
  • A Lighthouse test on one computer, with the CPU and network slowed down.
  • CrUX p75 LCP for your site on phones over the last 28 days.
  • Your app sends INP and page timings with navigator.sendBeacon when the page is hidden.
  • A tinybench or mitata test on your computer compares two functions that format text.
  • A DevTools recording of a checkout click on a test phone.
  • A loop runs two versions of a function 10,000 times and reports the median sample time.
Try it yourself
0 of 6 correct

Sort each measurement by where its numbers come from. Before a big change, look at more than one kind.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Send Core Web Vitals with the web-vitals libraryJavaScript
import { onCLS, onINP, onLCP } from "web-vitals"; function sendMetric({ name, value, id, delta, rating }) {  navigator.sendBeacon("/rum", JSON.stringify({    name,    value,    id,    delta,    rating,  }));} onCLS(sendMetric);onINP(sendMetric);onLCP(sendMetric);

web-vitals is a small library from Google that measures the Core Web Vitals for you. You can't run this example here, because this site does not install the library. Line by line:

  • Line 1 brings in three functions, one for each Core Web Vital.
  • Lines 3 to 11 define sendMetric. It sends one result to the server at /rum.
  • Lines 13 to 15 ask the library to call sendMetric for CLS, INP, and LCP.

Each result has these parts:

  • name: which number it is, such as "INP".
  • value: the number so far.
  • rating: good, needs improvement, or poor.
  • id: a unique name for this number on this page visit.
  • delta: how much the number changed since the last report.

The library may call sendMetric more than once for the same number, as it changes. So on the server, either replace the old row with the same id, or add up the delta values.

Pick the right web-vitals build

Start with the standard build. It gives the values and ratings. Use the attribution build only when you need to know the cause, such as which element was the LCP or which click was slow. Check that data for private user details before you send it.

If nobody clicks, the INP function may never be called. CLS and INP can be reported more than once as the visit goes on, so one report is not always the final answer.

Send saved timings when the user leaves the pageJavaScript
const queue = []; function rememberMetric(metric) {  queue.push({ name: metric.name, value: metric.value, path: location.pathname });} document.addEventListener("visibilitychange", () => {  if (document.visibilityState !== "hidden" || queue.length === 0) return;  const payload = JSON.stringify(queue.splice(0));  navigator.sendBeacon("/rum", payload);});
  • Lines 3 to 5 save each timing in a queue list, with the page path.
  • Line 7 listens for visibilitychange. This event fires when the user switches tab, closes the tab, or leaves the page.
  • Line 8 stops unless the page is now hidden and the queue has something in it.
  • Line 9 empties the queue into one string.
  • Line 10 sends it with navigator.sendBeacon. This sends a small message that still arrives even if the page is closing.

Collect timings from only some visits, not all of them. Remove personal details. Don't send web addresses that might hold secrets. Write down how long you keep the data. RUM is for finding slow pages, not for watching people.

Speed tests that don't fool you

BENCHMARKS

A benchmark is a speed test. A microbenchmark tests one small piece of code, often many times in a loop. They are easy to get wrong, for a few reasons.

First, JavaScript engines get faster as code runs. The engine watches which functions run often, then rewrites them into faster machine code. This is called just-in-time (JIT) compilation. So:

  • The first, "cold" run tells you how fast code is when the page has just started.
  • A warm-up is a set of runs you don't time, so the engine can speed the code up first. Runs after it tell you how fast the code is once the page has been in use for a while.

Pick the one that matches what your users actually do.

Other rules for a fair test:

  • Use the result. If nothing reads the result, the engine may skip the work, and you time nothing. A sink is a variable that collects every result so the work counts.
  • Give every run the same input.
  • Time it several times. Each timing is called a sample.
  • Report the median: the middle sample once they are sorted. Also look at how far apart the samples are.

Other things can make one sample slow: other work on the computer, the engine cleaning up memory (garbage collection), the rounded timer, or DevTools being open. If a test shows a big win, check it again in a real click or a recording.

A fairer small benchmarkPop out in the code editor (opens in a new tab)JavaScript
function median(values) {  const sorted = [...values].sort((a, b) => a - b);  const middle = Math.floor(sorted.length / 2);  return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;} function benchmark(label, fn, { warmup = 20, samples = 7, iterations = 200 } = {}) {  let sink = 0;  for (let i = 0; i < warmup; i += 1) sink += fn();  const durations = [];  for (let sample = 0; sample < samples; sample += 1) {    const start = performance.now();    for (let i = 0; i < iterations; i += 1) sink += fn();    durations.push(performance.now() - start);  }  return { label, median: median(durations), samples: durations.length, sink };} const report = benchmark("math", () => Math.sqrt(144));console.log(report.label);console.log(report.samples);console.log(report.median >= 0);console.log(report.sink > 0);
  • Lines 1 to 5 define median. It sorts a copy and returns the middle value.
  • Line 8 makes the sink, starting at 0.
  • Line 9 is the warm-up: 20 runs that are not timed. Each result is still added to the sink.
  • Lines 11 to 15 take 7 samples. Each sample times 200 runs and saves how long they took.
  • Line 16 returns the label, the median, the number of samples, and the sink.
  • Lines 20 to 23 print math, 7, and true twice. They don't print a time, because it changes on every device.
Step through a benchmark: warm up, then report the median
Step 0 of 5Ready
Your turn: follow the blue line

Step through a fairer benchmark: warm up, take several samples, use every result, and report the median instead of one lucky time.

Running in
  1. script
Next: line 19
function median(values) {  const sorted = [...values].sort((a, b) => a - b);  const middle = Math.floor(sorted.length / 2);  return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;} function benchmark(label, fn, { warmup = 20, samples = 7, iterations = 200 } = {}) {  let sink = 0;  for (let i = 0; i < warmup; i += 1) sink += fn();  const durations = [];  for (let sample = 0; sample < samples; sample += 1) {    const start = performance.now();    for (let i = 0; i < iterations; i += 1) sink += fn();    durations.push(performance.now() - start);  }  return { label, median: median(durations), samples: durations.length, sink };} console.log(report.label);console.log(report.samples);console.log(report.median >= 0);console.log(report.sink > 0);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
One slow sample barely moves the medianPop out in the code editor (opens in a new tab)JavaScript
const durations = [8, 10, 9, 80, 11]; // made-up numbers, not a real timing
const sorted = [...durations].sort((a, b) => a - b);
const middle = sorted[Math.floor(sorted.length / 2)];

console.log("sample count", durations.length);
console.log("median is finite", Number.isFinite(middle));
console.log("median is below the outlier", middle < sorted.at(-1));

These five numbers are made up. One of them, 80, is an outlier: a sample far away from the others. Line 2 sorts a copy. Line 3 picks the middle value, 10. The average would be 23.6, pulled up by the 80. The printed lines check the sample count and that the median stays below the outlier.

One cold run or the median of warm runs
The benchmark helper this panel runsPop out in the code editor (opens in a new tab)JavaScript
function median(values) {  const sorted = [...values].sort((a, b) => a - b);  const middle = Math.floor(sorted.length / 2);  return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;} function benchmark(label, fn, { warmup = 20, samples = 7, iterations = 200 } = {}) {  let sink = 0;  for (let i = 0; i < warmup; i += 1) sink += fn();  const durations = [];  for (let sample = 0; sample < samples; sample += 1) {    const start = performance.now();    for (let i = 0; i < iterations; i += 1) sink += fn();    durations.push(performance.now() - start);  }  return { label, median: median(durations), samples: durations.length, sink };} const report = benchmark("math", () => Math.sqrt(144));console.log(report.label);console.log(report.samples);console.log(report.median >= 0);console.log(report.sink > 0);
Reportwaiting

Waiting for your browser to run the benchmark.

Try it yourself
Runs in your browser

Don't focus on the exact milliseconds. Compare the two ways: one cold time, or several samples after a warm-up, summed up by their median.

Before you trust a benchmark, think about: DevTools' own work, the warm-up, whether the result is used, how much samples differ, and whether the test looks like real use.

Libraries such as tinybench and mitata do the warm-up, the samples, and the report for you. You still have to choose real-looking inputs, close DevTools when it matters, compare medians and the spread of samples, and check the win in the real page.

Common misconceptions

"The average shows what users feel."

The average hides the slow requests. Look at p50 for a typical user, p75 for your target, and p95 for the slowest users.

"If a microbenchmark gets faster, the app gets faster."

Not always. The code may rarely run. The engine may have skipped the work in the test. Or the network and drawing the page may take most of the time anyway.

"A test with DevTools open is the same as real use."

DevTools adds its own work, which can change the timings. Use it to find the problem, then check the fix in normal conditions.

"Real-user data means I don't need lab recordings."

Real-user data shows where users are slowed down. A lab recording shows why, and lets you test a fix before you ship it.

"Marks delete themselves."

Marks stay in the timeline until you clear them or the page closes. Clear marks you only needed for one measure.

The table below compares five timing tools that are easy to mix up. Each row gives the tool, what it gives you, and when to use it.

Timing tools that are easy to mix up
ToolWhat it gives youWhen to use it
Date.now()The date and time on the computer's clock, in milliseconds.Writing down when something happened. Don't use it to time work, because the clock can be changed.
performance.now()Milliseconds since the page started. It only moves forward.Timing short pieces of work. Browsers round it a little for safety, often to about 0.1 ms.
User TimingYour own named mark and measure entries, with an optional detail object.Timing the steps of your own app, so they show in the DevTools Timings track.
DevTools recordingA timeline of tasks, network requests, frames, screenshots, and which functions ran.Finding where the time went during one real click or page load.
RUMTimings sent from your real users' browsers.Watching p75 and p95 by page, device type, region, and app version.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upWork out a percentile

Use the rank method from this lesson. What is p75 for these samples?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const samples = [120, 80, 1000, 160, 240];
// sorted: 80, 120, 160, 240, 1000
// p75 rank: Math.ceil(0.75 * 5)

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFind the clock bug

    What time does the code print first?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    let wallClock = 1000;
    const DateNow = () => wallClock;
    
    const started = DateNow();
    wallClock -= 50; // someone sets the clock back
    const elapsed = DateNow() - started;
    
    console.log(elapsed);
    console.log("performance.now is monotonic instead");

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeClean up User Timing marks

      Which method of performance deletes the marks after you have made a measure?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the most self time in a flame chart

        Which function has the most self time?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        checkout: 25 ms total, 0 ms self
        parseCart: 4 ms total, 4 ms self
        priceItems: 14 ms total, 0 ms self
        readPrices: 5 ms total, 5 ms self
        applyDiscounts: 9 ms total, 9 ms self
        renderSummary: 7 ms total, 7 ms self

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeReport a benchmark fairly

          Instead of one cold time, which number should a fair benchmark report?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          // Fill in the last word: warm up, keep a sink, take many samples, report the ____.

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz: check your understanding

            8 QUESTIONS

            For each question, ask: what was measured, where did the number come from, and what could make it wrong?

            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Why does this lesson use p75 and p95, not only the average, to judge how fast a page is?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this percentile code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const samples = [120, 80, 1000, 160, 240];
              const sorted = [...samples].sort((a, b) => a - b);
              const rank = Math.ceil((75 / 100) * sorted.length);
              console.log(sorted[rank - 1]);

              Choose an answer to see the explanation.

            3. Question 3 of 8Why is performance.now() better than Date.now() for timing how long something takes?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does this Date.now() code print first?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let wallClock = 1000;
              const DateNow = () => wallClock;
              const started = DateNow();
              wallClock -= 50;
              console.log(DateNow() - started);

              Choose an answer to see the explanation.

            5. Question 5 of 8Where do your User Timing marks show up in a Chrome DevTools Performance recording?

              Choose an answer to see the explanation.

            6. Question 6 of 8How do you read one bar in a flame chart?

              Choose an answer to see the explanation.

            7. Question 7 of 8What is the difference between RUM and a Lighthouse test?

              Choose an answer to see the explanation.

            8. Question 8 of 8Which benchmark result should you trust most?

              Choose an answer to see the explanation.

            Key takeaways

            • Measure the slow thing the user notices first. If you speed things up without measuring, you may fix the wrong part.
            • Use percentiles, especially p75 and p95, not only the average.
            • performance.now(), marks, measures, observers, and the navigation entry give you timings straight from the browser.
            • A DevTools recording shows tasks, your marks, screenshots, a flame chart, and Insights on one timeline.
            • Real-user data shows where users are slowed down. Lab recordings show why. Benchmarks need a warm-up, a used result, many samples, and a median.

            Remember the one-liner.
            Measure first, pick the slowest part second, speed it up last, and check the fix with the same measurement.

            Up next: Keeping the main thread responsive.

            CompleteFrontend Clear concepts. Working examples.