Reading performance data
Histograms, percentiles, sample sizes, and confidence, explained with performance data. A recorded day of 384 visits in Chrome, each with its own device and network: cumulative charts, three ways to compute p75, bootstrap error bars, and a traffic mix that changes by the hour.
- 01Read a whole day of visitsSee the shape in a histogram, and read any percentile off a cumulative chart.
- 02Put an error bar on a percentileKnow why tools give different p75s for the same visits, and bootstrap a 90% interval.
- 03Tell a real change from chance or mixKnow how many visits a change needs, and how a changing mix of visitors moves p75 on its own.
One day of real visits
Picture the Leaf and Kettle team on a Monday morning. They open the dashboard for the mint tea page. It shows one number for each hour of the day before: the p75 LCP.
That number has two parts, both from earlier lessons:
- LCP, Largest Contentful Paint, is when the biggest image or block of text appeared. On this page, it's the photo of mint tea.
- The p75, the 75th percentile, is a time that three visits in four matched or beat. Building a RUM pipeline found it by lining the visits up, quickest first.
Here are three of the hours:
// p75 LCP for three hours of the day, in ms. The page never changed.const p75 = { "11:00": 232, "22:00": 920, "23:00": 640 }; console.log(`From 11:00 to 22:00, p75 rose by ${p75["22:00"] - p75["11:00"]} ms.`);console.log(`From 22:00 to 23:00, it fell by ${p75["22:00"] - p75["23:00"]} ms.`);- Line 1 is a comment. A millisecond (ms) is a thousandth of a second.
- Line 2 holds the p75 for three hours: the hours that start at 11:00, at 22:00 and at 23:00.
- Line 4 prints
From 11:00 to 22:00, p75 rose by 688 ms. - Line 5 prints
From 22:00 to 23:00, it fell by 280 ms.
So did the page break at ten at night, and mend itself by eleven? Nobody shipped anything all day. Here is every hour at once:
You can know the answer here, because the course built this day on purpose. It made 384 visits to a copy of the page in Chrome: 24 hours of 16 visits each. Each visit drew its own CPU speed and network at random, set through Chrome's DevTools protocol. Only one thing followed a plan: how many visits in each hour came from phones. It went from 4 in 16 around midday to 12 in 16 at night. The page itself never changed.
So nothing broke. Two other things moved the number:
- The mix. At 11:00, 4 of the 16 visits came from phones. At 22:00, 12 did. Phones took longer to show the photo: over the whole day, phones had a p75 of 932 ms and desktops 220 ms. So an hour with more phones had a slower p75.
- Chance. 22:00 and 23:00 both had 12 phones. Same page, same mix. But each hour drew different visitors, on different networks, and their p75s landed 280 ms apart.
You step on the bathroom scale, and it reads 61.2 kg. You step off, and on again: 60.8. Once more: 61.0. You didn't gain or lose anything in that minute. The scale just never reads exactly the same twice. So when it says you lost 300 g since last week, the first question is: is that more than the wobble?
- In real life: Three readings a minute apart
- In field data: Two hours with the same mix, 280 ms apart
- In real life: You didn't change in that minute
- In field data: The page didn't change all day
- In real life: How far the readings wobble
- In field data: The error bar on a p75
- In real life: A change bigger than the wobble
- In field data: A change you can believe
A p75 can move for three reasons: the page changed, the mix of visitors changed, or chance brought different visitors. Before you blame the page, put an error bar on the number and check the mix. An error bar is the range a number would likely land in if you measured it again.
The rest of this lesson takes the day apart in four steps:
- Look at the shape of all 384 visits, with a histogram and a cumulative chart.
- Measure how much one hour's p75 wobbles, and put an error bar on it.
- Work out how many visits make the error bar small enough.
- See how the mix of visitors moves p75 through the day.
Histograms and cumulative charts
Before you trust one number, look at what it sums up: all the visits.
A histogram does that. It splits the times into equal ranges, called bins, such as 0 to 249 ms and 250 to 499 ms. It counts the visits in each bin, and draws a bar for each count. NIST's handbook of statistics says a histogram is there "to graphically summarize the distribution of a univariate data set." In plain words: to show the shape of one list of numbers.
Here is the whole day as a histogram. Each row is one bin, and the last row holds everything from 2,000 ms up. The buttons switch to one hour or the evening, and to a second kind of chart you'll meet in a moment:
- 0 to 249 ms148
- 250 to 499 ms80
- 500 to 749 ms73
- 750 to 999 ms39
- 1,000 to 1,249 ms23
- 1,250 to 1,499 ms12
- 1,500 to 1,749 ms2
- 1,750 to 1,999 ms3
- 2,000 ms or more4
Each row is one 250 ms bin. Its bar and number show how many visits had an LCP in that bin. The 90% interval in the title comes from 500 bootstrap resamples.
Most visits bunch up in the first rows: 148 of the 384 took under 250 ms. Then the bars shrink, row by row. A few visits trail far behind. The slowest took 4,256 ms, more than 50 times the quickest, which took 80 ms.
That trailing end is called a long tail. Draw time from left to right, and the tail is on the right. NIST calls this shape skewed right: "the tail is on the right side." It also says why times often look like this: "data that have a lower bound are often skewed right." A visit can't take less than nothing. But a slow network or a busy phone can make it take many times longer than usual.
Asha times her trip to work every day for a month. Most days it takes about 25 minutes. On three days she just missed her train and waited for the next one, and those trips took close to an hour. Her average trip comes out longer than most of her days, because three long ones pull it up. Her middle day still says about 25 minutes.
- In real life: One day's trip
- In field data: One visit's LCP
- In real life: The three days she missed the train
- In field data: The long tail: a few very slow visits
- In real life: Her average trip
- In field data: The mean, pulled up by the tail
- In real life: The middle day of the month
- In field data: The median, or p50
- In real life: The trip three quarters of the way along, shortest first
- In field data: The p75
The day's visits behave the same way:
- The mean, every LCP added up and divided by 384, was 513 ms.
- The median, the middle visit, was 364 ms.
- The p75 was 704 ms.
The mean was slower than 233 of the 384 visits. The tail pulled it up, like Asha's missed trains. NIST says that for skewed data, the mean and the median "are markedly different". Why it matters: one very slow visit can move the mean a lot, and the p75 by one place in line at most. That's why this course reads field data with percentiles.
Adding the bins up
A histogram answers "what does the day look like?". It's less handy for another question: "what share of visits were quicker than this?" For that, you add the bins up as you go.
Asha could ask: on how many days was I at work within 30 minutes? She'd count the days under 10 minutes, then add the days under 20, then the days under 30.
NIST calls this a cumulative histogram. Each bar holds the count "for that bin plus all bins for smaller values". Divide each count by the total, and you get a share. Here it is with the day's real bins:
// The day's 384 LCPs, counted in 250 ms bins: under 250 ms, 250 to 499 ms, and so on.const counts = [148, 80, 73, 39, 23, 12, 2, 3, 4]; let below = 0;for (const [bin, count] of counts.entries()) { below += count; console.log(`Under ${(bin + 1) * 250} ms: ${Math.round((below / 384) * 100)}% of visits`); if (below / 384 >= 0.75) break;}- Line 2 is recorded: the day's 384 LCPs, counted into 250 ms bins. 148 visits took under 250 ms, 80 took 250 to 499 ms, and so on.
- Line 4 starts a running count at 0. Line 6 adds each bin to it.
- Line 7 prints the running count as a share of all 384 visits.
- Line 8 stops once the share reaches 75%.
- It prints
Under 250 ms: 39% of visits,Under 500 ms: 59% of visitsandUnder 750 ms: 78% of visits.
The share passed 75% inside the bin from 500 to 749 ms, so that's where the p75 is. The bins can't say exactly where, because they only count.
A cumulative chart also works the other way round. Pick a time, and read the share of visits under it:
// The same counts. The last bin holds every visit of 2,000 ms or more.const counts = [148, 80, 73, 39, 23, 12, 2, 3, 4]; const slowest = counts.at(-1);console.log(`${slowest} visits took 2,000 ms or more.`);console.log(`${Math.round(((384 - slowest) / 384) * 100)}% were under 2,000 ms.`);- Line 2 holds the same counts. The last one is the bin for 2,000 ms or more.
- Line 4 takes that last count with
counts.at(-1): the item one from the end. - Line 5 prints
4 visits took 2,000 ms or more.Line 6 prints99% were under 2,000 ms.
Now drop the bins and use every visit. Sort all 384 LCPs. For each one, mark the share of visits at or below it. The marks join up into a curve that climbs from 0% to 100%:
Across from 75%, the curve sits at 704 ms: the day's exact p75. Press Cumulative in the playground above to see the same idea in bins. Each row shows the share of visits below its end, and the highlighted row holds the p75.
CrUX, Chrome's public field data, keeps its numbers this way too. Its methodology says: "Most metric values within the CrUX dataset are represented as histograms of values and bin sizes". Why it matters: from bins you can work out any share yourself, and you can see the tail that one number hides.
Percentiles and their error bars
Back to the team's question. The p75 fell from 920 ms at 22:00 to 640 ms at 23:00. Is either number exact? Two things make a p75 less exact than it looks. The first is small: how your tool picks it. The second is big: which visitors happened to come.
Three ways to pick the p75
A PE teacher lines up the class by height, shortest first, and points at the child three quarters of the way along. Three quarters of the class are that tall or shorter. With 16 children, that spot falls between the 12th and the 13th child. One teacher points at the 12th. Another points just past the 12th. A third points most of the way to the 13th.
- In real life: The class, shortest first
- In field data: The visits' LCPs, sorted
- In real life: The child three quarters of the way along
- In field data: The p75
- In real life: A spot between two children
- In field data: A spot such as 12.75, between visit 12 and visit 13
- In real life: Teachers who point at different spots
- In field data: Tools that work out p75 in different ways
That pointing is all a percentile is. NIST puts it this way: "Percentiles split a set of ordered data into hundredths." The 75th percentile is three quarters of the way up the ordered list.
Take the evening hour from 20:00 to 21:00. It had 16 visits. The first way to pick is nearest rank, the method from Building a RUM pipeline: work out 75% of the count, round up, and read that visit.
// LCP of the 16 visits between 20:00 and 21:00, sorted, in ms.const lcps = [128, 148, 156, 236, 320, 396, 436, 560, 652, 672, 680, 772, 864, 892, 916, 932]; const rank = Math.ceil(0.75 * lcps.length);console.log(`Nearest rank: visit ${rank} in line, ${lcps[rank - 1]} ms`);- Line 2 is recorded: the hour's 16 LCPs, sorted from quickest to slowest.
- Line 4 works out the rank: 0.75 × 16 is 12.
Math.ceil()rounds up when the answer isn't a whole number. - Line 5 prints
Nearest rank: visit 12 in line, 772 ms. It readslcps[rank - 1], because arrays count from 0.
Nearest rank always picks a real visit. Other tools pick a spot between two visits, and work out a value part of the way from one to the next. NIST's handbook gives its own way, and describes the one Excel uses:
- NIST multiplies 0.75 by one more than the count: 0.75 × 17 = 12.75. That's three quarters of the way from visit 12 to visit 13.
- The other way adds 1 to 0.75 times one less than the count: 1 + 0.75 × 15 = 12.25. NIST says it "is the method used by Excel and is the default method for R."
// The same 16 visits, sorted, in ms.const lcps = [128, 148, 156, 236, 320, 396, 436, 560, 652, 672, 680, 772, 864, 892, 916, 932]; // The value at a spot such as 12.75: three quarters of the way from visit 12 to visit 13.const at = (spot) => { const k = Math.floor(spot); return lcps[k - 1] + (spot - k) * (lcps[k] - lcps[k - 1]);};console.log(`NIST, spot ${0.75 * (lcps.length + 1)}: ${at(0.75 * (lcps.length + 1))} ms`);console.log(`Excel and R, spot ${1 + 0.75 * (lcps.length - 1)}: ${at(1 + 0.75 * (lcps.length - 1))} ms`);- Line 2 holds the same 16 visits.
- Lines 5 to 8 make a function,
at(), that takes a spot such as 12.75. Line 6 keeps its whole part, 12. - Line 7 starts at visit 12, 772 ms, and adds the fraction of the gap to visit 13, 864 ms. For 12.75 that's 772 + 0.75 × 92.
- Line 9 prints
NIST, spot 12.75: 841 ms. Line 10 printsExcel and R, spot 12.25: 795 ms.
Same 16 visits, three answers: 772, 841 and 795 ms. NIST says these methods give "fairly similar, but not exactly the same (particularly for small samples), results." With the whole day's 384 visits, the three gave 704, 707 and 705 ms. Why it matters: know which method your tool uses, and don't compare a p75 from one tool with a p75 from another.
The same mix, a different p75
The method moved the hour's p75 by up to 69 ms. Now the bigger question. If the same kind of visitors came again, would they give the same p75?
The day can answer that. Ten of its hours had exactly the same mix: 12 phones and 4 desktops. Same page, same mix. Only the visitors were different.
// p75 LCP of the ten hours with 12 phones and 4 desktops, in ms.// Hours 0 to 5, then 20 to 23. Same page, same mix.const p75s = [896, 708, 1008, 768, 728, 872, 772, 688, 920, 640]; console.log(`The quickest of these hours: ${Math.min(...p75s)} ms`);console.log(`The slowest of these hours: ${Math.max(...p75s)} ms`);- Lines 1 and 2 are comments: they say which hours these are.
- Line 3 is recorded: each hour's p75, in the order of the day.
- Line 5 prints
The quickest of these hours: 640 ms. Line 6 printsThe slowest of these hours: 1008 ms.
That's the bathroom scale. Ten readings of the same thing, from 640 to 1,008 ms. With only 16 visits, the p75 is the 12th in line, so one or two slow phones decide it. This wobble is the p75's uncertainty: how much it would move if you drew another set of visitors. Why it matters: the drop from 920 to 640 ms at 23:00 wasn't news. Hours with this mix landed anywhere in that range.
An error bar from one set of visits
The day had ten hours with the same mix. Real data rarely gives you that. Usually you have one set of visits, and one p75. How can you tell how much it would wobble?
NIST's handbook offers the bootstrap. It "is used to estimate the uncertainty of a statistic": of a number worked out from data, like a p75. The idea is to treat your visits as the whole crowd, and draw new samples from them. It's like drawing names from a hat, and putting each name back before the next draw. Step by step:
- Make a new list the same size as yours, by picking visits from yours at random. Put each one back after you pick it, so "any data point can be sampled multiple times or not sampled at all". This new list is a resample.
- Work out the p75 of the resample.
- Do it again, many times. NIST: "500 subsamples is usually sufficient."
- Sort the 500 p75s, and keep the middle 90%: from the 25th to the 475th. NIST calls that range a 90% confidence interval. It's the error bar.
Here is one resample of the evening hour:
// LCP of the 16 visits between 20:00 and 21:00, in the order they came, in ms.const lcps = [436, 560, 892, 156, 396, 772, 916, 672, 932, 128, 680, 236, 652, 148, 320, 864]; // A small random number maker that gives the same numbers on every run.let seed = 1;const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647; const resample = lcps.map(() => lcps[Math.floor(random() * lcps.length)]);console.log(`One resample: ${resample.join(", ")}`);console.log(`It holds ${new Set(resample).size} different visits of the 16.`);- Line 2 is recorded: the hour's 16 LCPs, in the order the visits came.
- Lines 5 and 6 make a small random number maker. Each call gives a number from 0 to 1.
Math.random()would give new numbers on every run. This one gives the same numbers each time, so the page prints what this text says. - Line 8 builds the resample. For each of the 16 places,
Math.floor(random() * lcps.length)picks a whole number from 0 to 15, and takes that visit. - Line 9 prints
One resample: 436, 892, 652, 672, 932, 156, 436, 680, 680, 320, 916, 932, 148, 436, 436, 932. - Line 10 prints
It holds 10 different visits of the 16.
Look at the repeats. The 436 ms visit came up four times, and the 932 ms visit three times. Six visits didn't come up at all, including the 772 ms one. This resample is a slightly different evening, made from the same kind of visitors. Sort it, and its 12th visit, its p75, is 892 ms, not 772. Now do that 500 times:
// The same 16 visits, the same random numbers, and nearest rank for p75.const lcps = [436, 560, 892, 156, 396, 772, 916, 672, 932, 128, 680, 236, 652, 148, 320, 864];let seed = 1;const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1]; const p75s = [];for (let i = 0; i < 500; i++) p75s.push(p75(lcps.map(() => lcps[Math.floor(random() * lcps.length)])));p75s.sort((a, b) => a - b);console.log(`The hour's p75: ${p75(lcps)} ms. The middle 90% of 500 resamples: ${p75s[24]} to ${p75s[474]} ms.`);- Lines 2 to 4 are the same visits and the same random number maker.
- Line 5 is nearest rank, as a function.
- Line 8 does the last example's line 8, 500 times. Each time, it builds a resample and keeps its p75.
- Line 9 sorts the 500 p75s, quickest first.
- Line 10 reads the 25th and the 475th,
p75s[24]andp75s[474], because arrays count from 0. It printsThe hour's p75: 772 ms. The middle 90% of 500 resamples: 652 to 892 ms.
So the honest way to report that hour is "p75 772 ms, 90% interval 652 to 892 ms". Its error bar is 240 ms wide.
Back to the team's two hours. Bootstrap each of them, and the bars overlap: 22:00's runs from 508 to 1,032 ms, and 23:00's from 544 to 824 ms. 23:00's whole bar sits inside 22:00's. When two error bars overlap that much, the gap between the two numbers can easily be chance. Proving a change worked shows proper tests for this.
More visits make the bar shorter. The picture shows the hour, the evening's 96 visits, and the whole day's 384:
The whole day's p75 was 704 ms, with a 90% interval from 656 to 748 ms: 92 ms wide. Why it matters: a p75 without its error bar is a guess with no size. Show both on a dashboard.
How many samples you need
One hour gave an error bar 240 ms wide. The whole day gave one 92 ms wide. So how many visits do you need before you can trust a p75?
A town asks 10 people if they like the new bus route, and 7 say yes. Another poll asks 1,000 people, and 700 say yes. Both say 70%. But you'd trust the second far more. In the small poll, two people answering the other way would move it to 50% or 90%. In the big poll, two people barely move it at all.
- In real life: The 10 people asked
- In field data: One hour's 16 visits
- In real life: The 1,000 people asked
- In field data: Hundreds of visits
- In real life: Two answers changing the result
- In field data: Two slow visits moving the p75
- In real life: How far the result could swing
- In field data: The width of the error bar
NIST's page on sample sizes starts with the same question: "How many measurements should be included in the sample?" Its answer: "Unfortunately, there is no correct answer without additional information (or assumptions)." For field data, the piece you have to choose yourself is how big a change you want to see.
For a mean, NIST gives a formula. In plain words, the error shrinks with the square root of the count. The square root of a number is the number that, multiplied by itself, gives it: the square root of 4 is 2. So four times the visits halve the error, and 16 times the visits divide it by 4.
// NIST's formula for a mean: the error shrinks with the square root of the count.// How much narrower than with 16 visits?for (const visits of [64, 256, 1024]) { console.log(`${visits} visits: ${Math.sqrt(visits / 16)} times narrower`);}- Lines 1 and 2 are comments.
- Line 3 steps through three sizes. Line 4 divides each one by 16, and takes the square root with
Math.sqrt(). - It prints
64 visits: 2 times narrower,256 visits: 4 times narrowerand1024 visits: 8 times narrower.
A p75 isn't a mean, so the course measured it instead. It drew 500 samples of each size from the day's 384 visits, picking at random and putting each visit back, like new visitors from the same mix. Then it kept the middle 90% of each size's 500 p75s:
// The middle 90% of p75 LCP from 500 samples of each size, drawn from the day's visits, in ms.const spread = { 16: [384, 948], 64: [576, 880], 256: [640, 752], 1024: [680, 744] }; for (const [visits, [low, high]] of Object.entries(spread)) { console.log(`${visits} visits: ${low} to ${high} ms, ${high - low} ms wide`);}- Line 2 comes from the recording: for each size, the lowest and the highest p75 of the middle 90%.
- Line 4 steps through the sizes.
Object.entries()gives each size with its pair of numbers. - It prints
16 visits: 384 to 948 ms, 564 ms wideand64 visits: 576 to 880 ms, 304 ms wide. - Then
256 visits: 640 to 752 ms, 112 ms wideand1024 visits: 680 to 744 ms, 64 ms wide.
Each step has four times the visits. The spread got 1.9, 2.7 and 1.8 times narrower: about half each time, as the square root says, though not exactly. From 16 visits to 1,024, it got 8.8 times narrower. The formula for a mean said 8.
Now turn that into a rule you can use: the error bar must be narrower than the change you're looking for. Say you want to notice the p75 getting 100 ms worse. With 16 or 64 visits, the spread alone is more than three times wider than that. With 1,024 visits, it's 64 ms wide, so a 100 ms change is bigger than the wobble.
Higher percentiles need more visits still. web.dev explains why the Core Web Vitals use the 75th percentile, not the 95th. With 100 visits, "it would take just 5 outlier samples for the 95th percentile value to be affected by the outliers." CrUX only includes a page once it has a minimum number of visitors. "An exact number is not disclosed", it says, but it was chosen so there are "enough samples to be confident in the statistical distributions".
Building a RUM pipeline saw a p75 of four visits swing from 380 to 684 ms. Segmenting field data said four visits are too few to judge. Now you can see why: here, even 16 visits gave a spread 564 ms wide.
Seasonality and traffic mix
More visits shrink the wobble. They can't fix the other thing that moved the dashboard: the mix. Over the day, the hours' p75s ran from 232 to 1,008 ms. Some of that was chance. To see the rest, put the hours together into four quarters of six hours each, with 96 visits per quarter:
// Each quarter of the day: how many of its 96 visits were phones, and p75 LCP for everyone, in ms.const quarters = { night: { phones: 72, p75: 872 }, morning: { phones: 40, p75: 616 }, afternoon: { phones: 32, p75: 484 }, evening: { phones: 66, p75: 736 },}; for (const [name, quarter] of Object.entries(quarters)) { console.log(`${name}: ${Math.round((quarter.phones / 96) * 100)}% phones, p75 ${quarter.p75} ms`);}- Lines 2 to 7 are recorded: how many of each quarter's visits came from phones, and its p75 LCP.
- Line 10 works out the share of phones, and prints it with the p75.
- It prints
night: 75% phones, p75 872 msandmorning: 42% phones, p75 616 ms. - Then
afternoon: 33% phones, p75 484 msandevening: 69% phones, p75 736 ms.
Sort the quarters by their share of phones, and you've sorted them by p75 too. The more phones, the slower. But did the page get slower at night? Split each quarter by device:
// The same quarters, one device at a time: p75 LCP in ms.const quarters = { night: { phones: 1008, desktops: 208 }, morning: { phones: 1000, desktops: 224 }, afternoon: { phones: 936, desktops: 220 }, evening: { phones: 868, desktops: 208 },}; for (const [name, p75] of Object.entries(quarters)) { console.log(`${name}: phones ${p75.phones} ms, desktops ${p75.desktops} ms`);}- Lines 2 to 7 are recorded: each quarter's p75 for phones alone, and for desktops alone.
- It prints
night: phones 1008 ms, desktops 208 msandmorning: phones 1000 ms, desktops 224 ms. - Then
afternoon: phones 936 ms, desktops 220 msandevening: phones 868 ms, desktops 208 ms.
Desktops stayed between 208 and 224 ms all day. Phones stayed between 868 and 1,008 ms. Neither got slower at night. What changed was how many of each came. The afternoon looked quick because two visits in three came from desktops.
A swimming pool posts the average lap time for each hour. Early in the morning, the swimming club trains, and laps are quick. After school, families come with small children, and laps are slow. Nobody thinks the water got thicker at four o'clock. The pool is the same. Who swims in it changed, and it changes the same way every weekday.
- In real life: The pool
- In field data: The page, which never changed
- In real life: Club swimmers and families
- In field data: Desktops and phones
- In real life: The average lap time each hour
- In field data: Each hour's p75
- In real life: This Monday at 7:00 against last Monday at 7:00
- In field data: The same hours and days, compared
A pattern like this has a name: seasonality. NIST's handbook defines it as "periodic fluctuations": ups and downs that come back on a schedule. The day here had one by the hour. Real traffic can also change by weekday and by time of year. A sale, a holiday or an advertising campaign can bring in a different crowd. web.dev warns that over short periods, "RUM data can be unduly impacted by fluctuations in website traffic and visitors."
Segmenting field data showed the same trap with a release: its p75 looked eight times better, because most of its visits came from desktops. Here it happened with no release at all, just the clock. Three habits keep the mix from fooling you:
- Compare the same times. Put this Monday evening next to last Monday evening, not next to this Monday afternoon.
- Use whole weeks. A window of whole weeks holds the same number of each weekday. CrUX's window is a whole number of weeks. The exercises ask how many days it covers.
- Split by segment. Compare phones with phones, and desktops with desktops. Here, each device stayed put while the total moved.
Two things moved the day's p75, and neither was the page. Read across each row to tell them apart:
| Ask | When chance moved it | When the mix moved it |
|---|---|---|
| What changed? | Which visitors happened to come. The mix stayed the same. | The share of each kind of visitor, such as phones. |
| An example from the day | 22:00 and 23:00 both had 12 phones. Their p75s were 920 and 640 ms. | 11:00 had 4 phones and 22:00 had 12. Their p75s were 232 and 920 ms. |
| Do more visits help? | Yes. Four times the visits make the error bar about half as wide. | No. More visits of a different mix are still a different mix. |
| What should you do? | Put an error bar on the number before you call it a change. | Compare the same hours and days, or split by segment. |
Why it matters: check both before you blame the page.
Which tool answers it?
Each question below is answered best by one of the tools in this lesson. Sort it to that tool.
- Is there a second bump of slow visits?
- How far does the slow tail reach?
- What share of visits was under 2,000 ms?
- What was the 90th percentile?
- Can I trust a p75 from 16 visits?
- Did p75 really move, or is it chance?
- Why is p75 worse every evening?
- Did phones or desktops get slower?
Sort each question by the tool that answers it.
Common mix-ups
- "A percentile has one right formula." No. The same 16 visits gave 772, 841 and 795 ms. Tools pick differently when the spot falls between two visits.
- "A p75 from an hour of visits is exact." No. Hours with the same page and the same mix ran from 640 to 1,008 ms. One hour's 90% interval ran from 652 to 892 ms.
- "Twice the visits, half the error." No. It takes about four times the visits, because the error shrinks with the square root of the count.
- "A worse evening means a regression." No. The evening had 69% phones, and the afternoon 33%. Each device's p75 barely moved.
- "More visits fix everything." No. They shrink the wobble from chance. A different mix stays different.
- "With enough visits, the mean is fair." No. With 384 visits, the mean was still slower than 233 of them. More visits don't remove a long tail.
Practice exercises
Answer first, then use the hints.
The loop now stops at 50% instead of 75%. What does the last line print?
// The day's 384 LCPs, counted in 250 ms bins: under 250 ms, 250 to 499 ms, and so on.
const counts = [148, 80, 73, 39, 23, 12, 2, 3, 4];
let below = 0;
for (const [bin, count] of counts.entries()) {
below += count;
console.log(`Under ${(bin + 1) * 250} ms: ${Math.round((below / 384) * 100)}% of visits`);
if (below / 384 >= 0.5) break;
}Under 500 ms: 59% of visits. The share passes half in the second bin, so the day's median is somewhere from 250 to 499 ms.
Both lines now work out the median, the 50th percentile. What does the NIST line print?
// The same 16 visits, sorted, in ms.
const lcps = [128, 148, 156, 236, 320, 396, 436, 560, 652, 672, 680, 772, 864, 892, 916, 932];
// The value at a spot such as 8.5: halfway from visit 8 to visit 9.
const at = (spot) => {
const k = Math.floor(spot);
return lcps[k - 1] + (spot - k) * (lcps[k] - lcps[k - 1]);
};
console.log(`NIST, spot ${0.5 * (lcps.length + 1)}: ${at(0.5 * (lcps.length + 1))} ms`);
console.log(`Excel and R, spot ${1 + 0.5 * (lcps.length - 1)}: ${at(1 + 0.5 * (lcps.length - 1))} ms`);NIST, spot 8.5: 606 ms: halfway between 560 and 652. Excel's spot is 8.5 too. For the median, the two methods always pick the same spot.
In the playground, what is the 90% interval for the evening's p75, in ms?
672 to 824 ms, around a p75 of 736 ms. The evening's 96 visits gave a bar 152 ms wide: shorter than the hour's 240 ms, longer than the day's 92 ms.
In the whole day's cumulative chart, what share of visits had an LCP under 1,000 ms?
89%. So 44 of the day's 384 visits took a second or more.
NIST's page on sample sizes has a table for a mean. In it, α is the risk of a false alarm, and β is the risk of missing a real shift. A standard deviation, σ, measures how spread out the data is. In the row where α is 0.05 and β is 0.10, the table needs 11 measurements to spot a shift of one standard deviation. How many does it need for a shift half that size?
43: about four times 11. To spot a change half as big, you need about four times the measurements. That's the square root rule again.
How many days of traffic does CrUX's sliding window cover?
28 days: four whole weeks, so every weekday counts four times. web.dev suggests looking at your RUM data over 28 days too, when you compare it with CrUX.
Check your understanding
For each question, ask three things. What's the shape of the data? How sure is the number? Did the mix change?
Question 1 of 8What does the second line of this percentile example print?
Read the code, then predict// The same 16 visits, sorted, in ms. const lcps = [128, 148, 156, 236, 320, 396, 436, 560, 652, 672, 680, 772, 864, 892, 916, 932]; // The value at a spot such as 12.75: three quarters of the way from visit 12 to visit 13. const at = (spot) => { const k = Math.floor(spot); return lcps[k - 1] + (spot - k) * (lcps[k] - lcps[k - 1]); }; console.log(`NIST, spot ${0.75 * (lcps.length + 1)}: ${at(0.75 * (lcps.length + 1))} ms`); console.log(`Excel and R, spot ${1 + 0.75 * (lcps.length - 1)}: ${at(1 + 0.75 * (lcps.length - 1))} ms`);Choose an answer to see the explanation.
Question 2 of 8What does the third line of this quarters example print?
Read the code, then predict// Each quarter of the day: how many of its 96 visits were phones, and p75 LCP for everyone, in ms. const quarters = { night: { phones: 72, p75: 872 }, morning: { phones: 40, p75: 616 }, afternoon: { phones: 32, p75: 484 }, evening: { phones: 66, p75: 736 }, }; for (const [name, quarter] of Object.entries(quarters)) { console.log(`${name}: ${Math.round((quarter.phones / 96) * 100)}% phones, p75 ${quarter.p75} ms`); }Choose an answer to see the explanation.
Question 3 of 8Two hours with the same mix had a p75 of 920 ms, then 640 ms. Nothing shipped. What does the drop tell you?
Choose an answer to see the explanation.
Question 4 of 8A dashboard shows a p75 from one hour of 16 visits. How much should you trust the exact number?
Choose an answer to see the explanation.
Question 5 of 8How does a bootstrap build each resample?
Choose an answer to see the explanation.
Question 6 of 8You collect four times as many visits. What happens to the spread of p75?
Choose an answer to see the explanation.
Question 7 of 8p75 LCP gets worse every evening, though nothing shipped. What's the first thing to check?
Choose an answer to see the explanation.
Question 8 of 8Why read a cumulative chart as well as a histogram?
Choose an answer to see the explanation.
Key takeaways
- A p75 can move because the page changed, the mix changed, or chance brought different visitors.
- A histogram shows the shape. A cumulative chart gives any percentile, and the share under any time.
- Percentile methods differ on small samples. Know which one your tool uses.
- Every percentile has an error bar. Bootstrap it: 500 resamples, then keep the middle 90%.
- Four times the visits make the error bar about half as wide. It must be narrower than the change you look for.
- The mix moves the number on its own. Compare the same hours and days, or split by segment.
Remember the one-liner.
Every number has an error bar and a mix behind it.
Sources
The facts on this page come from these docs:
- Histogram, NIST/SEMATECH e-Handbook of Statistical Methods.
- Histogram Interpretation: Skewed (Non-Normal) Right, NIST/SEMATECH e-Handbook of Statistical Methods.
- CrUX methodology, Chrome for Developers.
- Percentiles, NIST/SEMATECH e-Handbook of Statistical Methods.
- Bootstrap Plot, NIST/SEMATECH e-Handbook of Statistical Methods.
- Sample sizes required, NIST/SEMATECH e-Handbook of Statistical Methods.
- How the Core Web Vitals metrics thresholds were defined, web.dev.
- Seasonality, NIST/SEMATECH e-Handbook of Statistical Methods.
- Why is CrUX data different from my RUM data?, web.dev.
Coming next: Proving a change worked. You'll compare two sets of visits, before and after a change, and say how sure you are that it helped.