completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Noise and variability

Why the same test gives different results, and how to compare runs honestly. The store loaded over and over on one laptop: quiet, busy, three at once, traced task by task, and compared against itself and against a fix.

By the end, you can
  • 01
    Find where the noise comes fromName what moves a lab number when the page stays the same: the browser's order of work, the CPU throttling, and other work on the machine.
  • 02
    Read the shape before the medianLook at how the loads spread before you decide how many to take and which number to report.
  • 03
    Compare honestlyTake turns between versions, test the page against itself first, and count how often after beats before.

Same page, different numbers

You've changed something in the Leaf and Kettle store, and you want to know if the photo of mint tea now appears sooner. So first you measure the store as it is. You load it on your laptop with the CPU four times slower, like DevTools' mid-tier phone setting. The photo appears after 360 ms. To be sure, you load it again: 356 ms. Then 528. Then 644.

You didn't change anything between those loads. So which number is the store's real speed? And when you test your change, how will you know if a new number means it helped, or that you got lucky?

Here are those four loads. They're real: the course loaded the store in Chrome, one load after another.

Four loads, nothing changedPop out in the code editor (opens in a new tab)JavaScript
// The store, loaded four times in a row with 4x CPU. Nothing changed between loads.// Each number is one load's LCP, in milliseconds.const loads = [360, 356, 528, 644]; console.log(`The fastest load: ${Math.min(...loads)} ms.`);console.log(`The slowest load: ${Math.max(...loads)} ms.`);
  • Lines 1 and 2 are comments. LCP, Largest Contentful Paint, is the moment the biggest image or block of text appeared. On the store, that's the photo. A millisecond (ms) is a thousandth of a second.
  • Line 3 holds the four recorded LCPs, in the order the loads ran.
  • Lines 5 and 6 print The fastest load: 356 ms. and The slowest load: 644 ms.

The slowest load took 288 ms longer than the fastest: nearly twice as long. Nothing about the page changed. This lesson calls that wobble noise: the differences between runs of the same test, on the same page. How far the numbers reach, from the lowest to the highest, is their spread.

You've met noise before. In The Lighthouse score, the store scored 90 and then 89, like a bathroom scale that reads 60.2 kg and then 60.4. This lesson finds out where the wobble comes from, and what to do about it.

The course kept going. It loaded the store 45 times in a row, the same way each time. Here is every load, one dot each:

A dot plot of the store's 45 loads in a row with 4x CPU, by LCP from 300 to 700 ms. 14 loads between 348 ms and 368 ms, 8 loads between 512 ms and 544 ms, 23 loads between 640 ms and 672 ms. No load landed between the clumps.Each dot is one load300 ms400 ms500 ms600 ms700 ms14 loads8 loads23 loads
The same page, loaded 45 times in a row. The loads didn't blur around one middle: they fell into three clumps.

If noise were a gentle blur, the dots would pile up around one middle. They didn't. They fell into three clumps, with empty gaps between them. Something put each load in one clump or another. Finding that something is the first job of this lesson.

Real-life analogyThe same walk, a different time every morning

Ravi still times his walk to the tea shop every morning, as he did in Choosing test conditions: the same stopwatch, from his front door to the shop's door. Even so, one morning it takes 9 minutes 40 seconds, the next 10 minutes 5, the next 9 minutes 50. A traffic light was red, or green. A crowd filled the crossing. He can't control those, so no single morning tells him how long the walk takes. A week of mornings does.

In real life: One morning's time
In the lab: One load's LCP
In real life: The same stopwatch, from the same door to the same door
In the lab: The same page, the same settings, the same laptop
In real life: Red lights and crowds at the crossing
In the lab: What the page doesn't control: the browser's order of work, the CPU, other programs
In real life: A week of mornings
In the lab: Many loads of the same page

To find the store's red lights, the course loaded it four ways. In each round, it loaded the store once each way:

  • No throttling: the laptop at full speed.
  • 4x CPU: the page's main thread, the part of the browser that runs the page's JavaScript and draws it, made four times slower. That's DevTools' mid-tier phone preset.
  • 4x CPU, busy laptop: the same, while a program kept every core of the laptop busy.
  • 4x CPU, three at once: three loads started at the same moment.

It ran 9 rounds, taking turns, so anything that changed during the test hit all four ways alike. Each load ran in a fresh browser context, which is like a new private window: nothing saved from the load before. The store came from a server on the same laptop, so no real network was involved.

Pick a way. Each row is one load: the bar is the store's start-up work, and the mark is when the photo appeared.

Playground: the same page, load after load
9 loads0 to 708 ms
1532 ms
2652 ms
3644 ms
4644 ms
5540 ms
6660 ms
7648 ms
8528 ms
9388 ms

frame first short start-up work first long start-up work first LCP. Bars: the start-up work.

Try it yourself
4x CPU

The CPU four times slower, one load at a time, with nothing else running. Median LCP 644 ms, from 388 ms to 660 ms. Frame first, then the start-up work: 1 of 9. Short start-up work, then the frame: 3 of 9. Long start-up work, then the frame: 5 of 9.

Recorded in Chrome 156 on Apple M1 Pro (8 cores), from the page's own timing. The four ways took turns for 9 rounds, each load in a fresh browser context.
The main idea

A lab number is one sample from a spread. Before you trust it, find out where the spread comes from, look at its shape, and compare like with like. Nothing changed between the 9 loads with 4x CPU, and LCP still ranged from 388 ms to 660 ms.

Where noise comes from

Back to Ravi's red lights. What are the store's? Lighthouse's docs start with a warning: "Lighthouse performance scores will change due to inherent variability in web and network technologies, even if there hasn't been a code change." Then they list seven sources of it. Here they are in plain words:

  • Page nondeterminism: the page itself changes, like an A/B test or an ad.
  • Local network variability: your own network, such as a busy Wi-Fi router.
  • Tier-1 network variability: the long-distance links between networks.
  • Web server variability: a busy server answers more slowly.
  • Client hardware variability: a different computer gives different numbers.
  • Client resource contention: other programs competing for that computer.
  • Browser nondeterminism: the browser runs its own work differently from load to load.

Now cross off what can't apply to the store's test. The store runs nothing random and shows no ads. Its server was on the same laptop, and one laptop ran every load. That leaves two rows: the browser itself, and other work on the laptop.

The same three jobs, in a different order

To see what the browser did, the course recorded twelve more loads with a trace: the timeline of everything the browser did, the one DevTools' Performance panel shows. Every trace had the same three long jobs on the main thread:

  • Reading the photo. The photo, hero.svg, is a 580 KB drawing. The browser has to read, or parse, all of it before it can show it.
  • The start-up work. This is the store's DOMContentLoaded listener: code that runs once the HTML has been read. It builds a search index by sorting 200,000 old order prices, then draws 24 teas.
  • Drawing a frame. The browser works out where everything goes and paints it. The first frame with the photo in it gives the LCP.

Every load did all three, and reading the photo always came first. What changed was which of the other two came next:

Three traced loads of the store with 4x CPU: Frame first, then the start-up work: Parsing hero.svg at 25 ms for 221.4 ms, Drawing a frame at 252.3 ms for 94.2 ms, Start-up work at 348 ms for 279.1 ms; LCP 364 ms. Short start-up work, then the frame: Parsing hero.svg at 29.3 ms for 220.9 ms, Start-up work at 257.6 ms for 168.7 ms, Drawing a frame at 426.3 ms for 81.7 ms; LCP 524 ms. Long start-up work, then the frame: Parsing hero.svg at 20.9 ms for 217.4 ms, Start-up work at 245.7 ms for 276.8 ms, Drawing a frame at 522.6 ms for 104.7 ms; LCP 652 ms.Frame firsthero.svgframestart-upLCP 364 msShort start-up workhero.svgstart-upframeLCP 524 msLong start-up workhero.svgstart-upframeLCP 652 ms0 ms200 ms400 ms600 ms
Three loads of the same page. Each did the same three long jobs. Only their order, and how long the shaded start-up work took, changed when the photo appeared.

In the top row, the frame went first, and the photo showed at 364 ms. In the bottom row, the start-up work went first. The frame waited for it, and LCP came at 652 ms. The middle row had a shorter start-up work, then the frame: 524 ms.

Real-life analogyOne cook, two jobs, and which goes first

A small café has one cook. Your plate is ready to go out, and a big pile of dishes is waiting to be washed. The cook can only do one job at a time. If they send out your plate first, you eat at once. If they start on the dishes, your plate sits on the counter until the last dish is dry. Same cook, same two jobs: only the order changed, and your wait nearly doubled.

In real life: The one cook
In the lab: The page's main thread
In real life: Sending out your plate
In the lab: Drawing the frame that shows the photo
In real life: The pile of dishes
In the lab: The start-up work
In real life: Which job the cook picks up first
In the lab: Which task the browser runs first
In real life: Your plate waiting on the counter
In the lab: A later LCP

The page doesn't choose the order. The browser does, and it doesn't always choose the same way. The browser also can't paint in the middle of a job: the HTML standard's event loop draws frames between tasks, never during one. Lighthouse's docs call this browser nondeterminism: "Browsers have inherent variability in their execution of tasks that impacts the way webpages are loaded."

Here are all twelve traced loads, sorted by the order their jobs came in:

Twelve loads, three ordersPop out in the code editor (opens in a new tab)JavaScript
// Twelve traced loads with 4x CPU. Each: [LCP, start-up work's start, its end], in ms.const loads = [  [652, 247.6, 520.8], [364, 349.2, 620.9], [524, 258.5, 402.8], [356, 343.4, 617.8],  [652, 254.6, 529.2], [652, 255.5, 528.9], [364, 346.8, 619.3], [548, 250.1, 448.4],  [368, 353.5, 498.5], [648, 250.9, 524.8], [652, 256.5, 530.1], [644, 251.6, 525.8],];// The browser paints between tasks, never in the middle of one.// So an LCP before the start-up work ended means the frame came first.function orderOf([lcp, start, end]) {  if (lcp < end) return "frame first";  return end - start < 200 ? "short start-up work first" : "long start-up work first";} const lcps = {};for (const load of loads) (lcps[orderOf(load)] ??= []).push(load[0]);for (const [order, list] of Object.entries(lcps)) {  console.log(`${order}: ${list.length} loads, LCP ${Math.min(...list)} to ${Math.max(...list)} ms`);}
  • Lines 3 to 5 hold the twelve recorded loads. Each has its LCP, and when the start-up work started and ended, from the page's own timing.
  • Lines 9 to 12 decide each load's order. Line 10 says the frame went first if the photo appeared before the start-up work ended. Otherwise the start-up work went first, and line 11 calls it short if it took under 200 ms.
  • Lines 14 to 18 group the LCPs by order and print one line per group.
  • The first line is long start-up work first: 6 loads, LCP 644 to 652 ms.
  • The second is frame first: 4 loads, LCP 356 to 368 ms.
  • The third is short start-up work first: 2 loads, LCP 524 to 548 ms.

The page's own timing comes from two APIs. LCP comes from LargestContentfulPaint entries. The start-up work's start and end come from the navigation entry's domContentLoadedEventStart and domContentLoadedEventEnd:

Why it matters: the store's clumps aren't random fuzz. Each clump is one order of the same three jobs. A number from one load tells you which order that load happened to get, not how fast the store is.

Four times slower, but not exactly

The order wasn't the only thing that moved. Here is the start-up work in the 9 rounds, with no throttling and with 4x CPU:

The start-up work, at full speed and at 4xPop out in the code editor (opens in a new tab)JavaScript
// The store's start-up work in the nine rounds, in ms: with no throttling, and with 4x CPU.const startup = {  "no throttling": [40.7, 40.8, 40.9, 41.4, 34.9, 35.1, 41.3, 41.1, 41.3],  "4x CPU": [146.3, 274.1, 272.1, 272.2, 145.5, 273.9, 272.6, 143.6, 276.4],};const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];const usual = median(startup["no throttling"]); for (const [way, list] of Object.entries(startup)) {  const low = Math.min(...list);  const high = Math.max(...list);  console.log(`${way}: ${low} to ${high} ms, ${(low / usual).toFixed(1)} to ${(high / usual).toFixed(1)} times as long`);}
  • Lines 3 and 4 hold the start-up work's recorded length in each round.
  • Line 7 takes the median length with no throttling as the usual length.
  • Line 12 prints each way's shortest and longest run, and how many times the usual length each is.
  • It prints no throttling: 34.9 to 41.4 ms, 0.9 to 1.0 times as long.
  • Then it prints 4x CPU: 143.6 to 276.4 ms, 3.5 to 6.8 times as long.

"4x" didn't mean four times as long. The same code took 3.5 to 6.8 times as long. It ran at two speeds: about 146 ms in 3 rounds, and about 274 ms in the other 6.

On an empty page, the same setting was steady. In Choosing test conditions, DevTools' CPU benchmark, a small test loop, scored 3,192 on this laptop at full speed. At 4x it scored 774.5: about four times slower, run after run. Inside a page load, with other work around it, the slowdown wandered.

So how does Chrome slow a CPU down? DevTools' CPU setting sends Chrome the command Emulation.setCPUThrottlingRate. Chrome hands the rate to its CPU throttler. On Mac and Linux, Chrome's source shows how the throttler works:

  • A separate thread wakes up about every 200 microseconds and sends the page's main thread a signal: a tap on the shoulder from the operating system. A microsecond is a thousandth of a millisecond.
  • The main thread stops what it's doing. It works out how long it ran since its last stop, counting at most 1,000 microseconds.
  • Then it waits, checking the clock, for run_duration_us * throttling_rate_percent / 100 - run_duration_us. At 4x, that's three times as long as it just ran.
Chrome's CPU throttler at 4x: a signal interrupts the page's main thread about every 200 microseconds, and the thread then waits 3 times as long as it just ran: 200 microseconds of work, then 600 of waiting.No throttlingruns4xrunswaitsrunswaits0 µs800 µs1600 µs
At 4x, the page's main thread runs for a moment, then waits 3 times as long. A timer and a signal on your own laptop do the slowing.

It's like slowing a runner by blowing a whistle every few steps, and making them stand still three times as long as they just ran. A timer and a signal on your own laptop do the slowing. So 4x is what Chrome aims for, not what every task gets.

Why it matters: a CPU setting doesn't give every load the same slow computer. It brings noise of its own. Compare runs made with the same setting on the same laptop, and never read 4x as an exact multiplier.

A busy laptop, and three loads at once

The other row left on the docs' list is client resource contention: other programs competing for the same computer. The docs warn: "Running multiple instances of Lighthouse at once also typically distorts results due to this problem." The first playground tried two kinds of busy laptop. Here are their loads next to the loads with the laptop alone:

Alone, busy, and three at oncePop out in the code editor (opens in a new tab)JavaScript
// Nine rounds with 4x CPU, taking turns: each load's LCP in ms.const lcp = {  alone: [532, 652, 644, 644, 540, 660, 648, 528, 388],  busy: [580, 612, 492, 636, 604, 628, 352, 616, 624],  together: [    444, 444, 424, 396, 400, 392, 416, 424, 400,    388, 384, 676, 428, 432, 424, 408, 592, 696,    384, 396, 396, 708, 416, 704, 396, 400, 684,  ],};const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)]; for (const [way, list] of Object.entries(lcp)) {  console.log(`${way}: median ${median(list)} ms, ${Math.min(...list)} to ${Math.max(...list)} ms`);}
  • Line 3 holds the nine loads with 4x CPU and nothing else running.
  • Line 4 holds nine loads made while a program kept one thread spinning on each of the laptop's 8 cores, like a big build running in the background.
  • Lines 6 to 8 hold 27 loads: three at a time, for nine rounds.
  • Line 14 prints each way's median and range.
  • The first line is alone: median 644 ms, 388 to 660 ms.
  • The second is busy: median 612 ms, 352 to 636 ms.
  • The third is together: median 416 ms, 384 to 708 ms.

You might expect a busy laptop to make every load slower. It didn't. Three loads at once made the median 228 ms faster. The order had changed: with three at once, the frame went first in 21 of 27 loads. Alone, it went first in 1 of 9.

Why it matters: a faster number from a busy machine isn't good news. It's a different test. Run one test at a time, on a machine doing nothing else, and write down what else was running.

How many runs you need

You've met the usual answer already. In The Lighthouse score, you stepped on the bathroom scale a few times and took the middle reading. Scripting Lighthouse ran Lighthouse several times for the same reason. Lighthouse's docs put a number on it: "The median Lighthouse score of 5 runs is twice as stable as 1 run."

The median is the middle value once the values are sorted. The middle one, not the lowest. Here are the store's first five loads:

The lowest, and the middlePop out in the code editor (opens in a new tab)JavaScript
// The store's first five loads in a row, with 4x CPU. LCP in ms.const loads = [360, 356, 528, 644, 652];const sorted = [...loads].sort((a, b) => a - b); console.log(`Sorted: ${sorted.join(", ")} ms`);console.log(`The lowest is ${sorted[0]} ms. The middle one is ${sorted[2]} ms.`);
  • Line 2 holds the first five of the 45 loads in a row.
  • Line 3 sorts a copy of them, from fastest to slowest.
  • Line 5 prints Sorted: 356, 360, 528, 644, 652 ms.
  • Line 6 prints The lowest is 356 ms. The middle one is 528 ms.

It's tempting to report the lowest: it's your page at its best. But the lowest is the luckiest load. On the bathroom scale, it's the reading you'd like to believe, not the one you should. Only 14 of the 45 loads landed in the fast clump. 23 took 640 ms or more.

So is 528 ms the store's number? Take the next five loads, and the middle one is 544 ms. The five after that: 356 ms. To see how far this goes, the course cut all 45 loads into groups, the way several people might each run the test a few times and report a median:

Medians of groups of loadsPop out in the code editor (opens in a new tab)JavaScript
// 45 loads with 4x CPU, one after another: LCP in ms.const lcp = [  360, 356, 528, 644, 652, 672, 652, 356, 544, 348, 640, 356, 644, 352, 352,  648, 352, 644, 356, 660, 516, 640, 656, 640, 648, 356, 640, 640, 640, 644,  360, 512, 512, 524, 520, 652, 524, 368, 656, 652, 644, 364, 652, 644, 360,];const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)]; for (const size of [1, 3, 5, 9]) {  const medians = [];  // Cut the loads into groups of this size, in order, and keep each group's median.  for (let start = 0; start + size <= lcp.length; start += size) {    medians.push(median(lcp.slice(start, start + size)));  }  console.log(`groups of ${size}: ${medians.length} medians, ${Math.min(...medians)} to ${Math.max(...medians)} ms`);}
  • Lines 3 to 5 hold all 45 loads, in the order they ran.
  • Line 9 tries four group sizes: single loads, then groups of 3, 5 and 9.
  • Lines 12 to 14 cut the loads into groups of that size, in order, and keep each group's median.
  • Line 15 prints how many medians there were, and the lowest and highest.
  • The first two lines are groups of 1: 45 medians, 348 to 672 ms and groups of 3: 15 medians, 352 to 652 ms.
  • The last two are groups of 5: 9 medians, 356 to 652 ms and groups of 9: 5 medians, 356 to 644 ms.

More loads barely helped. Single loads ranged over 324 ms. Medians of nine still ranged over 288 ms. Nine people, each taking a median of five, could report anything from 356 ms to 652 ms for the same page.

Real-life analogyA bus that waits at a railway gate

Asha's bus to work crosses a railway line. If the gate is up, the bus reaches her stop at 8:00. If a train is passing, the bus waits, and comes at 8:10. It never comes at 8:05. Take the middle of five mornings, and it might be 8:00 one week and 8:10 the next. More mornings don't settle it. The honest answer is "8:00 about half the time, 8:10 the rest."

In real life: The gate, up or down
In the lab: Which job the main thread runs first
In real life: 8:00 or 8:10, never 8:05
In the lab: LCP in one clump or another, never between
In real life: The middle of five mornings
In the lab: The median of five loads
In real life: "8:00 about half the time"
In the lab: How many loads landed in each clump

The store's loads work like that bus. A median lands in whichever clump holds its group's middle load. About half of the 45 loads were in the two fast clumps (22), and the rest in the slow one (23). So the median flips between clumps, however many loads you take. In Reading distributions, a spread with two humps was called bimodal. The store's has three.

Pick a group size. Each dot is one load, coloured by its order. Each flat line is one group's median:

Playground: medians of groups
LCP of each load, in order300 ms to 700 ms

frame first short start-up work first long start-up work first a group's median

Try it yourself
Medians of 5

9 medians, from 356 ms to 652 ms. Each flat line is one group's median. It lands in whichever clump holds the group's middle load, so the medians still jump between clumps.

Recorded in Chrome 156 on Apple M1 Pro (8 cores): 45 loads of the store with 4x CPU, one after another. Each dot's colour is the order its main-thread jobs came in.

So how many loads do you need? It depends on the shape of the spread. Read each row of this table as a shape, an example from the store, and what to do:

How many loads you need depends on the shape
What the loads look likeAn example from the storeWhat to do
One clump around a middle.After the fix, all 15 loads landed within 40 ms of each other.Take five or more loads, and report the median.
One clump, and one odd load.With no throttling, the very first load took 176 ms. The other eight took 96 to 112 ms.Report the median: one odd load can't move it much. Then check whether the odd load comes back.
Two or more clumps.With 4x CPU, the store's 45 loads in a row fell into three clumps.Count the loads in each clump. Better, find what makes the clumps, and remove it.

Tools built for comparing versions take many samples. tachometer, a benchmarking tool from Google, takes "a minimum of 50 samples" from each version by default. Then it keeps taking more until the difference is clear, or its time runs out.

Why it matters: look at the loads before you decide how many to take. A handful is enough for one clump, but not for several.

Medians and stable environments

Keep the run nearest the middle

For a number to report, take the median. One odd load can drag an average a long way, but it can't move a median much, as Benchmarking without fooling yourself shows.

Sometimes you need a whole run, though: a trace to open in DevTools, or a report to share. You can't take the median of five traces, so you keep the run nearest the middle.

You met Lighthouse's helper for this in Scripting Lighthouse: computeMedianRun. Its source says what it looks for: "the run that's closest to the median of the FCP and the median of the TTI." Those are two metrics:

  • FCP, First Contentful Paint: when the first text or image appeared.
  • TTI, Time to Interactive: in Lighthouse's words, "the amount of time it takes for the page to become fully interactive." Since Lighthouse 10, the report doesn't show it, but "it is still accessible in the Lighthouse result JSON."

The source explains the choice: "We use FCP and TTI because they represent the earliest and latest moments in the page lifecycle." It skips the score on purpose. The source says: "We avoid the median of single measures like the performance score because they can still exhibit outlier behavior at the beginning or end of load."

The course ran Lighthouse five times on the store, with DevTools throttling, so each run was really measured. Here is computeMedianRun, shortened, on those five runs:

The run to keepPop out in the code editor (opens in a new tab)JavaScript
// Five Lighthouse runs of the store with DevTools throttling: score, FCP and TTI in ms.const runs = [  { score: 65, fcp: 3031.9, tti: 5301 },  { score: 63, fcp: 2872.3, tti: 5298.7 },  { score: 66, fcp: 3017.7, tti: 5287.7 },  { score: 64, fcp: 2880.3, tti: 5292.8 },  { score: 64, fcp: 2908.6, tti: 5310.8 },];const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];const fcp = median(runs.map((run) => run.fcp));const tti = median(runs.map((run) => run.tti));// computeMedianRun, shortened: the run closest to both medians wins. A tie goes to the lower TTI.const distance = (run) => (fcp - run.fcp) ** 2 + (tti - run.tti) ** 2;const kept = [...runs].sort((a, b) => distance(a) - distance(b) || a.tti - b.tti)[0]; console.log(`median FCP ${fcp} ms, median TTI ${tti} ms`);console.log(`kept: run ${runs.indexOf(kept) + 1}, score ${kept.score}`);console.log(`scores: ${runs.map((run) => run.score).join(", ")}`);
  • Lines 3 to 7 hold the five recorded runs: each one's score, FCP and TTI.
  • Lines 10 and 11 take the median FCP and the median TTI.
  • Line 13 measures how far each run is from both medians. Line 14 sorts the runs by that distance and keeps the closest. If two runs tie, the one with the lower TTI wins.
  • Line 16 prints median FCP 2908.6 ms, median TTI 5298.7 ms.
  • Line 17 prints kept: run 5, score 64. Line 18 prints scores: 65, 63, 66, 64, 64.

Lighthouse's real computeMedianRun kept the same run, run 5. Its score, 64, also happens to be the median score. Nothing makes sure of that: the score isn't what it compares.

These runs slowed the network too, so the photo took seconds to arrive. LCP came at about 5.3 seconds and barely moved: from 5,329.4 ms to 5,352.7 ms. The CPU's noise still showed in TBT, Total Blocking Time: how long, in total, long tasks blocked the main thread between FCP and TTI. It went from 155.3 ms to 248 ms.

A steady machine

A median calms the noise down. A steady machine makes less of it in the first place.

Real-life analogyA bathroom scale on a wobbly floor

Your bathroom scale stands on a loose floorboard. Each time you step on, the board bends a little differently, and the reading jumps by half a kilo. Move the scale onto solid tiles, and the readings settle. Only then can you tell whether you really lost 200 grams.

In real life: The loose floorboard
In the lab: A shared or busy test machine
In real life: Solid tiles
In the lab: A machine that runs only the test
In real life: Readings that jump by half a kilo
In the lab: LCP that jumps by hundreds of milliseconds
In real life: Telling whether you lost 200 grams
In the lab: Telling whether a small fix helped

Lighthouse's docs list ways to move your tests onto solid tiles:

  • Enough computer: "Minimum 2 dedicated cores (4 recommended)". A core is one of the processor's workers. Dedicated means nothing else shares it.
  • No shared cloud machines: avoid "burstable" or "shared-core" instance types. Some cloud machines run at a lower baseline speed and burst above it. Others share one physical core, taking turns with other machines.
  • One test at a time: "DO NOT collect multiple Lighthouse reports at the same time on the same machine." You saw why: three loads at once changed the order.
  • A nearby server: "Use localhost or a machine on the same exact network whenever stability is a concern." localhost means the same computer, as the store's server was here.
  • Nothing else running: "Isolate your client environment from external influences like anti-virus software and browser extensions."

Why it matters: a number without its setup can't be compared with anything. Next to every result, write down the machine, the Chrome version, the settings, and what else was running.

Comparing before and after

Now the question from the start: you've made a change. Did it help? The course tried a real fix. The store's start-up work builds a search index straight away, though nothing on the first screen needs it. The fix leaves out that one line, buildIndex();, at start-up. The Add to cart click still builds the index, as it always did.

Two habits make a comparison honest: take turns, and test the page against itself first.

Take turns

Suppose you load the old store ten times in the morning, and the fixed one ten times after lunch. If a backup started at lunch, every "after" load ran on a busier laptop. You'd be measuring the backup, not the fix.

So take turns instead: old, new, old, new. Then whatever changes over time hits both sides alike. Taking turns in a fixed order is called round-robin. tachometer works this way: "It round-robins between the two files, running each at least 50 times."

Test the page against itself first

An A/A test compares a page with itself: version A against the same version A. Nothing changed, so any difference it finds is noise. It tells you how big a difference noise alone can make.

Real-life analogySeven heads from a fair coin

You flip a coin ten times and get seven heads. Is the coin unfair? Probably not. A fair coin gives seven or more heads in ten flips about one time in six. Ten flips are too few to tell. Flip it a few hundred times, and a fair coin usually lands close to half heads.

In real life: Ten flips of one fair coin
In the lab: A few loads of the same page
In real life: Seven heads out of ten
In the lab: One side looking a little faster
In real life: About one time in six, by chance
In the lab: How often noise alone makes a difference that size
In real life: A few hundred flips
In the lab: Many loads, taking turns

The course ran fifteen rounds, taking turns: the store, the same store again, and the fix. First, the store against itself:

The store against itselfPop out in the code editor (opens in a new tab)JavaScript
// Fifteen rounds with 4x CPU, taking turns: the store, then the same store again. LCP in ms.const before = [524, 648, 624, 640, 364, 644, 652, 636, 640, 512, 640, 356, 640, 512, 520];const again = [644, 520, 660, 652, 372, 652, 356, 360, 652, 644, 648, 636, 660, 640, 640];const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];const gaps = before.map((ms, round) => Math.abs(again[round] - ms)); console.log(`Median before: ${median(before)} ms. Median again: ${median(again)} ms.`);console.log(`In one round, the same page differed by ${Math.max(...gaps)} ms.`);
  • Lines 2 and 3 hold the store's two sets of loads. They ran in turns, one from each set, round after round.
  • Line 5 works out, round by round, how far apart the two loads were.
  • Line 7 prints Median before: 636 ms. Median again: 644 ms.
  • Line 8 prints In one round, the same page differed by 296 ms.

Against itself, the store got 8 ms slower. That's a regression, a change for the worse, from nothing. And a single round could have "shown" a 296 ms difference. So one pair of loads proves nothing, and neither does a small gap between two medians.

Count the pairs

Medians hide a lot. Here is a fairer question: if you picked one load from each side at random, how often would the new side win? Pair every before load with every after load: 15 loads by 15, or 225 pairs. Then count the pairs where the after load was faster:

Before against again, and against the fixPop out in the code editor (opens in a new tab)JavaScript
// Fifteen rounds, taking turns: the store, the store again, and the fix. LCP in ms.const lcp = {  before: [524, 648, 624, 640, 364, 644, 652, 636, 640, 512, 640, 356, 640, 512, 520],  again: [644, 520, 660, 652, 372, 652, 356, 360, 652, 644, 648, 636, 660, 640, 640],  after: [380, 388, 392, 368, 388, 376, 364, 364, 352, 376, 384, 352, 380, 368, 384],};const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)]; // Pair every before load with every other load. Count the pairs where the other was faster; a tie counts half.function fasterShare(before, other) {  let wins = 0;  for (const a of before) for (const b of other) wins += b < a ? 1 : b === a ? 0.5 : 0;  return wins / (before.length * other.length);} for (const version of ["again", "after"]) {  const share = Math.round(fasterShare(lcp.before, lcp[version]) * 100);  console.log(`${version}: median ${median(lcp[version])} ms against ${median(lcp.before)}, faster in ${share}% of pairs`);}
  • Lines 3 to 5 hold the fifteen rounds: the store, the store again, and the fix.
  • Lines 10 to 14 pair every before load with every load of the other version. They count the pairs where the other was faster. A tie counts half.
  • Line 18 prints each version's median, and the share of pairs it won.
  • It prints again: median 644 ms against 636, faster in 34% of pairs.
  • Then it prints after: median 376 ms against 636, faster in 89% of pairs.

The store against itself won only 34% of its pairs. The fix won 89%, far beyond anything noise did in the A/A test. Not 100%: two of the store's own loads, 356 and 364 ms, had the frame go first, and were as fast as the fix.

Now look at the fix's range. Every load after the fix landed between 352 ms and 392 ms: one clump. With no index to build, the start-up work took under 10 ms, so it no longer raced the first frame. Pick a version to set beside the store. Each tick is one load, and the tall mark is the median:

Playground: before and after
LCP, each load and the median300 ms to 700 ms
The store636 ms
The store again644 ms
Try it yourself
An A/A test: the store against itself

Median 644 ms against 636 ms. Faster in 34% of the 225 pairs. Round by round, from -296 ms to +280 ms against the store. Nothing changed between the two, so every difference here is noise.

Recorded in Chrome 156 on Apple M1 Pro (8 cores): 15 rounds with 4x CPU, taking turns between the store, the store again and the fix. Each tick is one load's LCP, and the tall mark is the median.

So a change is believable when two things hold:

  • it's bigger than the difference your A/A test found;
  • it wins most of the pairs, not just the medians.

For small changes, you need many more loads and a confidence interval. tachometer's README explains one: "Loosely speaking, a confidence interval is a range of plausible values for a parameter like runtime." It reports "95% confidence intervals for the percent change from one benchmark to another." When that range spans zero, it can't name a winner: "We can't tell which is faster, because the difference was too small."

Why it matters: the fix, not the noise, deserves the credit only when it beats what the A/A test showed.

Adds noise or fights it?

Each item below makes lab results either noisier or steadier. Sort it, using what the store's loads showed and what the docs advise.

Adds noise or fights it?
  • Loading three pages at the same time
  • A cloud machine with a shared or burstable CPU
  • Judging a change from one load of each version
  • Browser extensions running during the test
  • Taking turns between before and after
  • Serving the test page from localhost
  • Reporting the median of several loads
  • Removing work that races the first frame
Try it yourself
0 of 8 correct

Sort each item by what it does to the spread of lab results.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A busy machine only makes loads slower." No. Three loads at once made the store's median 228 ms faster, because the frame went first more often.
  • "More runs always settle the median." No. With clumps, the median jumps between them. Medians of nine still ranged from 356 to 644 ms.
  • "4x CPU means everything takes four times as long." No. The store's start-up work took 3.5 to 6.8 times as long.
  • "The fastest run is the page's real speed." No. It's the luckiest run. Report the median.
  • "One faster load proves the fix." No. The same page, round by round, differed by up to 296 ms.
  • "Lighthouse keeps the run with the median score." No. It keeps the run closest to the median FCP and the median TTI.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upA slower busy load

One busy load took 900 ms instead of 636. What does the second line print now?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Nine rounds with 4x CPU, taking turns: each load's LCP in ms.
const lcp = {
  alone: [532, 652, 644, 644, 540, 660, 648, 528, 388],
  busy: [580, 612, 492, 900, 604, 628, 352, 616, 624],
  together: [
    444, 444, 424, 396, 400, 392, 416, 424, 400,
    388, 384, 676, 428, 432, 424, 408, 592, 696,
    384, 396, 396, 708, 416, 704, 396, 400, 684,
  ],
};
const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];

for (const [way, list] of Object.entries(lcp)) {
  console.log(`${way}: median ${median(list)} ms, ${Math.min(...list)} to ${Math.max(...list)} ms`);
}

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

    Exercise 2 · Warm-upGroups of fifteen

    Line 9 now tries groups of 15 too. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // 45 loads with 4x CPU, one after another: LCP in ms.
    const lcp = [
      360, 356, 528, 644, 652, 672, 652, 356, 544, 348, 640, 356, 644, 352, 352,
      648, 352, 644, 356, 660, 516, 640, 656, 640, 648, 356, 640, 640, 640, 644,
      360, 512, 512, 524, 520, 652, 524, 368, 656, 652, 644, 364, 652, 644, 360,
    ];
    const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];
    
    for (const size of [1, 3, 5, 9, 15]) {
      const medians = [];
      // Cut the loads into groups of this size, in order, and keep each group's median.
      for (let start = 0; start + size <= lcp.length; start += size) {
        medians.push(median(lcp.slice(start, start + size)));
      }
      console.log(`groups of ${size}: ${medians.length} medians, ${Math.min(...medians)} to ${Math.max(...medians)} ms`);
    }

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

      Exercise 3 · PracticeThe busy laptop's orders

      With the busy laptop, in how many of the nine loads did the long start-up work go first?

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

        Exercise 4 · PracticeThe fix's worst round

        Round by round, at most how many milliseconds slower than the store was the fix?

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

          Exercise 5 · ChallengeEnough memory

          Lighthouse's docs list the hardware for a steady test machine. At least how much memory (RAM) do they ask for?

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

            Exercise 6 · ChallengeHow long tachometer keeps going

            After its first 50 samples, tachometer keeps sampling until the difference is clear. By default, for at most how many minutes?

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

              Check your understanding

              For each question, ask where the spread comes from, what shape it has, and whether the comparison took turns.

              Noise and variability quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the third line of the alone, busy and together example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Nine rounds with 4x CPU, taking turns: each load's LCP in ms.
                const lcp = {
                  alone: [532, 652, 644, 644, 540, 660, 648, 528, 388],
                  busy: [580, 612, 492, 636, 604, 628, 352, 616, 624],
                  together: [
                    444, 444, 424, 396, 400, 392, 416, 424, 400,
                    388, 384, 676, 428, 432, 424, 408, 592, 696,
                    384, 396, 396, 708, 416, 704, 396, 400, 684,
                  ],
                };
                const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];
                
                for (const [way, list] of Object.entries(lcp)) {
                  console.log(`${way}: median ${median(list)} ms, ${Math.min(...list)} to ${Math.max(...list)} ms`);
                }

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the second line of the pairs example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Fifteen rounds, taking turns: the store, the store again, and the fix. LCP in ms.
                const lcp = {
                  before: [524, 648, 624, 640, 364, 644, 652, 636, 640, 512, 640, 356, 640, 512, 520],
                  again: [644, 520, 660, 652, 372, 652, 356, 360, 652, 644, 648, 636, 660, 640, 640],
                  after: [380, 388, 392, 368, 388, 376, 364, 364, 352, 376, 384, 352, 380, 368, 384],
                };
                const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];
                
                // Pair every before load with every other load. Count the pairs where the other was faster; a tie counts half.
                function fasterShare(before, other) {
                  let wins = 0;
                  for (const a of before) for (const b of other) wins += b < a ? 1 : b === a ? 0.5 : 0;
                  return wins / (before.length * other.length);
                }
                
                for (const version of ["again", "after"]) {
                  const share = Math.round(fasterShare(lcp.before, lcp[version]) * 100);
                  console.log(`${version}: median ${median(lcp[version])} ms against ${median(lcp.before)}, faster in ${share}% of pairs`);
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7Nothing changed between the store's loads with 4x CPU. Why did LCP fall into clumps?

                Choose an answer to see the explanation.

              4. Question 4 of 7From five loads of the same page, which number should you report?

                Choose an answer to see the explanation.

              5. Question 5 of 7Medians of nine loads still ranged from 356 to 644 ms. Why didn't more loads settle the median?

                Choose an answer to see the explanation.

              6. Question 6 of 7What is an A/A test?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why take turns between before and after, instead of running every before load first?

                Choose an answer to see the explanation.

              Key takeaways

              • A lab number is one sample from a spread, even when nothing about the page changes.
              • The browser's own order of work, the CPU throttling and other work on the machine all move it.
              • A busy machine doesn't only add time. It can change the test, and even make it look faster.
              • Report the median, not the lowest. When you need a whole run, keep the one nearest the middle.
              • Look at the shape first. With clumps, medians jump between them, however many loads you take.
              • Take turns between versions, run an A/A test, and count how often after beats before.

              Remember the one-liner.
              One run is a sample: measure the spread, then compare like with like, taking turns.

              Sources

              The facts on this page come from these docs, standards and source files:

              Coming next: More lab tools. DevTools and Lighthouse aren't the only lab tools. You'll meet sitespeed.io, the Firefox Profiler, Safari's timelines and Perfetto, and when to reach for each.

              CompleteFrontend Clear concepts. Working examples.