Measuring performance
Learn to profile JavaScript bottlenecks with Performance API marks, DevTools flame charts, RUM data, and safer benchmarks.
- 01Measure the right thingUse percentiles, your own marks and measures, page-loading timings, and observers before you change any code.
- 02Read 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.
- 03Compare 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.
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.
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 FIRSTSay 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.
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.
Step through the percentile function. Watch how the one slow request becomes p95.
script
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));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 TIMINGThe 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.
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.
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.
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.
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
detailobject. - 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.
getEntriesByNamefinds entries by their name.
To get every measure, whatever its name, use getEntriesByType("measure").
Step through the measure helper. It saves two marks and a measure, then deletes the two marks.
script
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");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");Waiting for your browser to run the first task.
Run a task to save two marks and a measure with your browser's Performance API.
measure helper as the lesson. The times change with your device and what else the browser is doing.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
OBSERVEA 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.
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.
not reported herenot reported herenot reported herenot reported herenot reported herenot reported herenot reported herenot reported herenot reported hereThe 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.
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.
| Step | How to work it out | What the time means |
|---|---|---|
| DNS | domainLookupEnd - domainLookupStart | How long it took to turn the site name into a server address. |
| TCP | connectEnd - connectStart | How long it took to connect to the server. On HTTPS this includes the secure handshake. |
| TTFB | responseStart - requestStart | Time to first byte: from sending the request to getting the first piece of the answer. |
| DOMContentLoaded | domContentLoadedEventEnd - startTime | When the HTML was read and the defer scripts had run. |
| load | loadEventEnd - startTime | When images and other files had loaded and the load event had finished. |
The Performance panel in Chrome DevTools
TRACEDevTools answers the question "What happened while I clicked?" Here is how to use it:
- Open DevTools and choose the Performance panel.
- The Live metrics screen gives a quick look at the page's speed numbers.
- To see an interaction, press record, do the click, and stop. To see page loading, use record and reload.
- 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.
| Part | What it shows you |
|---|---|
| Live metrics screen | A 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 track | The jobs (tasks) the main thread ran. A task over 50 ms is a long task and gets a red triangle. |
| Bottom-up tab | The functions that did the most work themselves, so you see self time first. |
| Call tree tab | Calls from the top down, so you see which chain of calls took the most time in total. |
| Event log tab | Everything that happened, in exact order. |
| Insights sidebar | Likely 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.
- Pick one slow action you can repeat.
- Record it with the CPU and network slowed down to match real users.
- Look for one long task, or one wide block in the flame chart.
- 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 STACKSA 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.
checkout covers everything.function profileCheckout() { return profile("checkout", () => { parseCart(); priceItems(); renderSummary(); });} function priceItems() { readPrices(); applyDiscounts();}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.
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
RUMThere 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.
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?
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.
| Name | What it measures | Good if | A common reason it is bad |
|---|---|---|---|
| LCP | Largest Contentful Paint: how fast the main content shows up. | 2.5 s or less | The biggest picture or text block on screen shows up late. |
| INP | Interaction to Next Paint: how fast the page reacts to a click or key press. | 200 ms or less | The click has to wait while the main thread is busy. |
| CLS | Cumulative Layout Shift: how much things jump around. | 0.1 or less | Things 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.
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.
- 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.sendBeaconwhen 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.
Sort each measurement by where its numbers come from. Before a big change, look at more than one kind.
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
sendMetricfor 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.
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.
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
queuelist, 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
BENCHMARKSA 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.
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, andtruetwice. They don't print a time, because it changes on every device.
Step through a fairer benchmark: warm up, take several samples, use every result, and report the median instead of one lucky time.
script
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);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.
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);Waiting for your browser to run the benchmark.
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.
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.
| Tool | What it gives you | When 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 Timing | Your 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 recording | A timeline of tasks, network requests, frames, screenshots, and which functions ran. | Finding where the time went during one real click or page load. |
| RUM | Timings sent from your real users' browsers. | Watching p75 and p95 by page, device type, region, and app version. |
Practice exercises
5 EXERCISESUse the rank method from this lesson. What is p75 for these samples?
const samples = [120, 80, 1000, 160, 240];
// sorted: 80, 120, 160, 240, 1000
// p75 rank: Math.ceil(0.75 * 5)console.log(240);p75 is 240, because four of the five sorted samples are 240 ms or less.
What time does the code print first?
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");The first line printed is -50. Use performance.now() to time things, because it never goes backward.
Which method of performance deletes the marks after you have made a measure?
performance.clearMarks(start);
performance.clearMarks(end);Delete the marks once the measure exists, so a later run can't use old marks by mistake.
Which function has the most self time?
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 selfapplyDiscounts has 9 ms of self time, the most of its own work in the chart.
Instead of one cold time, which number should a fair benchmark report?
// Fill in the last word: warm up, keep a sink, take many samples, report the ____.return { median: median(durations), samples: durations.length, sink };Report the median and the number of samples, taken after a warm-up, and keep a sink so the results are used.
Quiz: check your understanding
8 QUESTIONSFor each question, ask: what was measured, where did the number come from, and what could make it wrong?
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.
Question 2 of 8What does this percentile code print?
Read the code, then predictconst 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.
Question 3 of 8Why is
performance.now()better thanDate.now()for timing how long something takes?Choose an answer to see the explanation.
Question 4 of 8What does this
Date.now()code print first?Read the code, then predictlet wallClock = 1000; const DateNow = () => wallClock; const started = DateNow(); wallClock -= 50; console.log(DateNow() - started);Choose an answer to see the explanation.
Question 5 of 8Where do your User Timing marks show up in a Chrome DevTools Performance recording?
Choose an answer to see the explanation.
Question 6 of 8How do you read one bar in a flame chart?
Choose an answer to see the explanation.
Question 7 of 8What is the difference between RUM and a Lighthouse test?
Choose an answer to see the explanation.
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.