completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Averages lie: reading distributions

Performance data is skewed and lumpy. Read it with histograms and percentiles, see which fixes move the 75th percentile, and segment before you conclude.

By the end, you can
  • 01
    Read the shape of the dataDrop visits into a histogram, choose how wide its bins are, and spot a long tail or two separate humps.
  • 02
    Pick the number that answers the questionSay what the median, the 75th and the 95th percentile each tell you, and why an average, or an average of percentiles, misleads.
  • 03
    Move the number that mattersPredict which fixes move the 75th percentile, and split visits into groups before you decide anything from them.

One number hides the shape

Ravi looks after the Leaf and Kettle website. In lesson 3.3, he met real user monitoring: a small script that measures each real visit and sends the numbers home. He has added one to the teas page. For each visit, it sends one number: the page's LCP, Largest Contentful Paint. That's the moment the main content appears. On the teas page, it's when the list of teas shows up.

The next morning, twenty visits have come in. Ravi wants one number he can tell the team. So he does what most people do first: he adds them all up and divides by twenty. That gives the average, which is also called the mean.

The course can't see Ravi's real visitors. So it borrows the four recorded loads that lesson 3.3 compared: a new phone, a regular coming back, a first visit on a mid-range phone, and Asha's cheap phone. Each visit below got the same LCP as one of those recorded loads. How many visits each kind made is made up.

One morning of visits, and their averagePop out in the code editor (opens in a new tab)JavaScript
// One morning on the teas page: when the teas appeared (LCP), in ms, for each visit.const visits = [  564, 564, 564, 564, 564, 564, // 6 visitors on new phones  808, 808, 808, 808, 808, 808, 808, 808, // 8 regulars, back again  2228, 2228, 2228, 2228, // 4 first visits on mid-range phones  7628, 7628, // Asha on her cheap phone, twice]; const total = visits.reduce((sum, ms) => sum + ms, 0);console.log(`${visits.length} visits. The average: ${Math.round(total / visits.length)} ms.`);
  • Line 1 is a comment. Times are in milliseconds (ms): thousandths of a second. So 1,000 ms is one second.
  • Lines 2 to 7 are the twenty visits, one number each. Each line holds one kind of visitor, fastest first.
  • Line 9 adds them all up. reduce walks along the list and keeps a running total, starting from 0.
  • Line 10 divides the total by the number of visits, 20. It prints 20 visits. The average: 1701 ms.

1,701 ms is about 1.7 seconds. That's under LCP's good limit of 2.5 seconds, from lesson 3.1. Ravi is pleased. But did any visitor actually wait 1.7 seconds? Here is what each visitor's screen showed at 1,701 ms:

Four phone screens of the teas page, one for each kind of visitor, each 1,701 ms after that visitor opened the page: the morning's average. new phones, 6 visits: the shop's name and the teas; the teas appeared at 564 ms. regulars, 8 visits: the shop's name and the teas; the teas appeared at 808 ms. first visits, 4 visits: the shop's name, and no teas yet; the teas appeared at 2,228 ms. Asha's phone, 2 visits: a blank screen; the teas appeared at 7,628 ms.At 1,701 ms, the averageThe teas page: the shop's name, Our teas, and the list of teas6 visitsnew phonesteas at 564 msThe teas page: the shop's name, Our teas, and the list of teas8 visitsregularsteas at 808 msOnly the shop's name at the top, and no teas yet4 visitsfirst visitsteas at 2,228 msblank2 visitsAsha's phoneteas at 7,628 ms
Each screen 1,701 ms after its visitor opened the page: the morning's average. They're real frames Chrome captured during the recorded loads. 14 visits already showed the teas. 4 showed only the shop's name, and Asha's screen was still blank.

Nobody was at the average.

  • Fourteen visits had shown the teas long before. The new phones got them at 564 ms, the regulars at 808 ms.
  • Four first visits showed only the shop's name. Their teas came later, at 2,228 ms.
  • Asha's screen was still blank. Her teas came at 7,628 ms, nearly six seconds after the average.

Why it matters: if Ravi tells the team "1.7 seconds", they'll picture every visitor waiting about that long. In fact, most waited less than a second, and Asha waited more than seven and a half.

Lesson 3.1 judged buses by people's real waits. Here is one person's month of waits:

Real-life analogyA bus that's 6 minutes late, on average

You take the same bus to work every day. Most days it's 2 minutes late. Twice a month, roadworks make it 40 minutes late. Over a month of 20 trips, that works out at about 6 minutes late, on average. But you never once waited 6 minutes. You had 18 easy days and 2 awful ones, and the average hides both.

In real life: Each day's wait at the stop
In web performance: Each visit's LCP
In real life: The usual days, 2 minutes late
In web performance: Most visits: the teas in under a second
In real life: The two days it was 40 minutes late
In web performance: Asha's two visits, 7,628 ms each
In real life: "6 minutes late, on average"
In web performance: The average LCP, 1,701 ms

The whole set of numbers, and the way they spread out, is called a distribution. The morning's distribution has a big bunch of fast visits, a smaller group of slower ones, and two very slow ones far away. The average squeezes all of that into one number, and the shape is lost.

There are better ways to read a distribution. Each gets its own section below:

  • a picture of its shape, called a histogram;
  • numbers that point at real visits, called percentiles;
  • groups of visitors hiding inside the data, called segments.
The main idea

Field data is a distribution: many numbers with a shape, not one number. Its average can describe nobody. Look at the shape first, in a histogram. Then describe it with percentiles, which point at real visits. And split it into groups before you decide anything.

Histograms and long tails

Twenty numbers in a list are hard to read, and a busy site gets thousands. You need to see them all at once, the way you'd sort marbles:

Real-life analogySorting marbles into jars

Someone tips a bag of marbles onto the table. You set out a row of jars, one for each size: tiny, small, medium, and so on up to huge. You drop each marble into the jar for its size. Now one look along the row tells you the story: most marbles are small, a few are medium, and two are giants.

In real life: One marble
In web performance: One visit
In real life: A jar for each size
In web performance: A bin: a range of times, like 1 to 2 seconds
In real life: How full each jar is
In web performance: How many visits took that long
In real life: The row of jars
In web performance: The histogram

A histogram is that row of jars, drawn as bars. You split the times into ranges of the same width, called bins. Then you count how many visits fall in each bin, and draw a bar that tall. This lesson calls the bins jars. Here are the morning's twenty visits, dropped into jars one second wide:

Drop each visit into a jarPop out in the code editor (opens in a new tab)JavaScript
const visits = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,  2228, 2228, 2228, 2228, 7628, 7628]; const jars = [0, 0, 0, 0, 0, 0, 0, 0]; // 0-1 s, 1-2 s, 2-3 s, and so on, up to 7-8 sfor (const ms of visits) {  const jar = Math.floor(ms / 1000);  jars[jar] = jars[jar] + 1;} jars.forEach((count, jar) => console.log(`${jar}-${jar + 1} s |${"#".repeat(count)}`));
  • Lines 1 and 2 are the morning's twenty visits again, fastest first.
  • Line 4 sets out eight empty jars. Jar 0 holds 0 to 1 second, jar 1 holds 1 to 2 seconds, and so on.
  • Lines 5 to 8 go through the visits one by one. Line 6 works out which jar each belongs in. Math.floor drops everything after the decimal point. So 2228 / 1000 is 2.228, which becomes 2: the jar for 2 to 3 seconds.
  • Line 7 adds one to that jar's count.
  • Line 10 prints one row per jar. forEach runs the printing once for each jar, and "#".repeat(count) writes one # for each visit in it.

Press Edit & run. It prints a histogram lying on its side. The first row is 0-1 s |##############, with fourteen #. Next comes an empty row, 1-2 s |, then 2-3 s |####. Four more empty rows follow, and the last row is 7-8 s |##. Here it is standing up:

A histogram of the morning's twenty visits in jars one second wide: 0 to 1 seconds, 14; 1 to 2 seconds, 0; 2 to 3 seconds, 4; 3 to 4 seconds, 0; 4 to 5 seconds, 0; 5 to 6 seconds, 0; 6 to 7 seconds, 0; 7 to 8 seconds, 2. The average, 1,701 ms, falls in an empty jar. A long tail stretches to the right.140400002average1,701 mslong tail012345678 s
The morning's visits in jars one second wide. Most sit in the first jar, a few in the third, and Asha's two far out on the right: a long tail. The average, 1,701 ms, lands in an empty jar.

Now the shape is easy to see:

  • A tall bunch on the left: 14 of the 20 visits showed the teas in under a second.
  • A smaller bunch: the 4 first visits, between 2 and 3 seconds.
  • Asha's two visits, far out on the right.

And the average? It lands in the jar for 1 to 2 seconds, which is empty.

A long tail on the slow side

Asha's two visits sit far from the rest and stretch the picture out to the right. A few values far out on one side like this are called a long tail. Asha waited more than 13 times as long as the new phones.

Page load times often have a long tail on the slow side. Here is why:

  • A visit can't be faster than its phone and network allow. That sets a floor.
  • There's no ceiling. A busy phone, a weak signal, a lost packet or an empty cache can each add more time.

Data like this is called skewed: lopsided, with a longer tail on one side. NIST's handbook of statistics notes that data with a lower limit are often "skewed right": the tail is on the right.

The tail is the bus's 40-minute days: only a few visits, but each one huge, so it pulls the average up. Ilya Grigorik said the same about the size of web pages: "averages are easily skewed by a few large numbers."

Choosing the jars

How wide should the jars be? It changes what you see:

  • Too wide, and nearly everything lands in one or two jars. The shape disappears.
  • Too narrow, with only a few visits, and most jars hold 0 or 1. The picture turns into noise: little spikes that mean nothing.
  • Where the row ends matters too. One very slow visit can squeeze everything else into one corner. So charts often stop at a round number and put every slower visit in the last jar, as the playgrounds below do.

Chrome's own field data, CrUX from lesson 3.3, uses very wide jars. Its API reports each metric as a histogram of just three. For LCP, they're 0 to 2.5 seconds, 2.5 to 4 seconds, and slower: good, needs improvement, and poor. Next to them it gives the 75th percentile. That number comes from all the visits, not from the three jars.

Real visits aren't four exact copies of four loads. Every phone and network is a little different, so real histograms are smoother than this one. The playground below makes one in your own browser. It fetches a tiny file from this site 100 times, one after another, and draws how long each request took:

Playground: your own distribution
What Time does, shortenedJavaScript
for (let i = 0; i < 100; i++) {  const fromCache = halfFromCache && i % 2 === 1;  const start = performance.now();  const response = await fetch(fromCache ? cachedUrl : `${url}?n=${i}`, {    cache: fromCache ? "force-cache" : "no-store",  });  await response.arrayBuffer();  times.push(performance.now() - start);}
All from the network0 of 100 requests
Average
none yet
Median (50th percentile)
none yet
75th percentile
none yet
95th percentile
none yet
Try it yourself
Your distribution

Press Time 100 requests. Your browser fetches a tiny file from this site 100 times, one after another, and draws how long each took. Then try Half from your cache, and the three bin widths.

Real requests from your browser to this site. Each is timed with performance.now(), from asking for the file to its last byte. The file is styles.css from the first lesson's tea shop: a few hundred bytes.

In the panel's code:

  • Line 2 sends every other request to the cache, when you choose Half from your cache.
  • Line 5 asks for no-store or force-cache. no-store always goes to the network. force-cache uses the copy the browser saved.

Run it a few times. The same tiny file takes a different time on every request. Look for the bunch and the tail, then try the three bin widths.

Median, 75th, and 95th percentiles

The average got pulled toward Asha's visits. Is there a number that doesn't get pulled? Think about people's heights:

Real-life analogyA basketball player walks into the room

Twenty people stand in a room. You work out their average height. Then a basketball player walks in, much taller than anyone else. The average height jumps up, though nobody else grew. Now line everyone up by height and look at the person in the middle. It's the same person as before, or their neighbour.

In real life: The people in the room
In web performance: The visits
In real life: The basketball player
In web performance: Asha's two very slow visits
In real life: The average height, which jumps
In web performance: The average LCP
In real life: The person in the middle of the line
In web performance: The median visit

The visit in the middle of the line is the median. Half the visits were as fast or faster, and half as slow or slower. Here is the morning with and without Asha's two visits:

Asha walks in: the average jumps, the middle staysPop out in the code editor (opens in a new tab)JavaScript
const others = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,  2228, 2228, 2228, 2228];const everyone = [...others, 7628, 7628]; // Asha's two visits join the line const average = (visits) => Math.round(visits.reduce((sum, ms) => sum + ms, 0) / visits.length);const middle = (visits) => visits[Math.ceil(visits.length / 2) - 1]; console.log(`Without Asha: average ${average(others)} ms, middle visit ${middle(others)} ms`);console.log(`With Asha: average ${average(everyone)} ms, middle visit ${middle(everyone)} ms`);
  • Lines 1 and 2 are the morning without Asha: 18 visits, fastest first.
  • Line 3 makes a new list: everything in others, then Asha's two visits at the end. The three dots ... copy one list into another.
  • Line 5 works out the average, the same way as the first example.
  • Line 6 picks the visit in the middle of the line: the 9th of 18, or the 10th of 20. Math.ceil rounds up, and - 1 is there because a list counts from 0.
  • Lines 8 and 9 print Without Asha: average 1042 ms, middle visit 808 ms and With Asha: average 1701 ms, middle visit 808 ms.

Asha's two visits pushed the average up by 659 ms. The middle visit didn't move at all. Why it matters: when someone asks "what does a typical visit feel like?", the median answers. The average answers a different question: how much waiting there was in total, shared out equally.

The NIST handbook explains why. In skewed data, "the mean will be pulled in the direction of the skewness". But the extreme values "do not distort the median since the median is based on ranks." A rank is a place in the line. With 20 visits, two share the middle: the 10th and the 11th. Statisticians take halfway between them. Here both are 808 ms, so the answer is the same.

Pointing three quarters of the way along

The median is one place in the line: halfway along. You can point at other places too.

Real-life analogyLining everyone up by how long they waited

Everyone who visited this morning lines up, the shortest wait at the front and the longest at the back. You walk three quarters of the way down the line and point. Three in four people stand at your finger or in front of it: they waited that long or less. One in four stands behind it.

In real life: The line, shortest wait at the front
In web performance: The visits, sorted fastest first
In real life: The person halfway along
In web performance: The median: the 50th percentile
In real life: The person three quarters along
In web performance: The 75th percentile
In real life: The person near the very back
In web performance: The 95th percentile

A percentile is the value at one of those places, named by how far along it is. The 75th percentile is the visit three quarters of the way along: 75% of visits were that fast or faster. The median is the 50th percentile. Lesson 3.1 put the 75th as passing three tests out of four. Here are three percentiles of the morning:

Point at three places in the linePop out in the code editor (opens in a new tab)JavaScript
const visits = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,  2228, 2228, 2228, 2228, 7628, 7628]; // already in line, fastest first const place = (p) => Math.ceil((p / 100) * visits.length); // a place in the line, counting from 1const at = (p) => visits[place(p) - 1]; // a list counts from 0, so the 1st visit is visits[0] console.log(`Median: visit ${place(50)} of ${visits.length}, ${at(50)} ms`);console.log(`75th percentile: visit ${place(75)} of ${visits.length}, ${at(75)} ms`);console.log(`95th percentile: visit ${place(95)} of ${visits.length}, ${at(95)} ms`);
  • Lines 1 and 2 are the morning's visits. They're already in line, fastest first.
  • Line 4 finds a place in the line. For the 75th percentile, 75% of 20 visits is 15, so it's place 15. Math.ceil rounds up, for counts that don't come out whole.
  • Line 5 takes the visit at that place. A list counts from 0, so place 15 is visits[14].
  • Lines 7 to 9 print Median: visit 10 of 20, 808 ms, 75th percentile: visit 15 of 20, 2228 ms and 95th percentile: visit 19 of 20, 7628 ms.

Read them as answers:

  • A typical visit showed the teas at 808 ms.
  • Three in four visits showed them by 2,228 ms. That's good: under 2.5 seconds.
  • The 95th percentile is Asha, at 7,628 ms: the slow end of the line.

Unlike the average, each is a real visit's time. Rounding the place up and taking the visit there is the nearest-rank method from lesson 1.3. Some tools take a point between two visits instead, and NIST says there's no single agreed way. Lesson 13.1, Reading performance data, compares them.

Each percentile answers its own question. Read each row across: a percentile, what it says, and what it's for.

Which percentile, for what
PercentileWhat it saysUse it for
Median (50th)Half the visits were this fast or faster.What a typical visit is like.
75thThree in four visits were this fast or faster.The Core Web Vitals. Most visits count, and a few odd ones can't move it.
95thOnly 1 visit in 20 was slower.The slowest visits, the long tail.
99thOnly 1 visit in 100 was slower.Very busy pages only. It needs many visits to stay steady.

Why the 75th for the Core Web Vitals?

Lesson 3.1 said a page passes when its LCP, INP and CLS are all good at the 75th percentile. web.dev chose the 75th for two reasons:

  • At least 3 in 4 visits get a good experience.
  • A few outliers, values far from the rest like Asha's, can't decide the result. With 100 visits, 5 outliers can move the 95th percentile. It takes 25 to move the 75th.

The 95th also needs many visits to stay steady. With 20 visits, it's the 19th, so one unlucky visit more or less can change it a lot. Lesson 13.1 shows how many visits a percentile needs.

You can't average percentiles

One more trap. Say a made-up site has two pages: a home page and a product page. Each has its own 75th percentile. Is the site's 75th percentile the average of the two? Try it:

Averaging percentilesPop out in the code editor (opens in a new tab)JavaScript
const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(0.75 * values.length) - 1]; const home = [900, 1000, 1100, 1200];const product = [1500, 2000, 2600, 3100, 3500, 4000, 4400, 5200]; console.log("home:", p75(home));console.log("product:", p75(product));console.log("average of the two:", (p75(home) + p75(product)) / 2);console.log("p75 of all visits:", p75([...home, ...product]));
  • Line 1 is lesson 3.1's percentile, squeezed onto one line: sort the values, then take the one 75% of the way along.
  • Line 3 is the home page's visits: four of them, all fast.
  • Line 4 is the product page's: eight of them, twice as many, and slower.
  • Lines 6 and 7 print home: 1100 and product: 4000. Line 8 averages the two and prints average of the two: 2550.
  • Line 9 puts all twelve visits in one line first, then takes the 75th percentile. It prints p75 of all visits: 3500.

Averaging said 2.55 seconds, just over LCP's good limit. The real answer is 3.5 seconds. Averaging treated the two pages as if they had the same number of visits, but the product page had twice as many. So put the visits together first, then take the percentile.

Bimodal data: cached vs uncached

Look at the morning's histogram again: a tall bunch under one second, an empty jar, then a smaller bunch between 2 and 3 seconds. Two bunches usually mean two different groups of visits got mixed together.

When a histogram has two humps, the data is called bimodal. "Bi" means two. A mode is the value that turns up most often: the top of a hump. The NIST handbook's advice for two humps is to find out why there are two separate groups.

It's like a café where some people order ahead on an app and others queue at the counter. The app orders are ready in a minute, the counter orders in ten. An average of five minutes would be a wait almost nobody had.

On a website, a common cause is the cache: the store of files a browser saved on earlier visits. In lesson 3.3, a regular's phone already had the teas page's files. Its teas came at 808 ms. A first visit on the same phone, with an empty cache, waited until 2,228 ms. Same page, same phone, same network: two humps. Here is what happens as first visits become a bigger share of the traffic:

Two humps, one percentilePop out in the code editor (opens in a new tab)JavaScript
const returning = 808;const firstVisit = 2228; for (const share of [10, 20, 30, 40]) {  const visits = [];  for (let i = 0; i < 100; i++) visits.push(i < 100 - share ? returning : firstVisit);  const mean = visits.reduce((sum, ms) => sum + ms, 0) / visits.length;  console.log(`${share}% first visits: mean ${Math.round(mean)} ms, p75 ${visits[74]} ms`);}
  • Lines 1 and 2 are the two recorded LCPs: the regular's, and the first visit's.
  • Line 4 tries four shares of first visits: 10, 20, 30 and 40 in every 100.
  • Line 6 makes 100 visits. The regulars come first, then the first visits. So the list is already in line, fastest first.
  • Line 7 works out the average.
  • Line 8 prints it next to visits[74]: the 75th visit in the line, which is the 75th percentile.
  • It prints 10% first visits: mean 950 ms, p75 808 ms and 20% first visits: mean 1092 ms, p75 808 ms.
  • Then 30% first visits: mean 1234 ms, p75 2228 ms and 40% first visits: mean 1376 ms, p75 2228 ms.

Watch the two numbers. The average slides up a little each time, and it always sits in the gap between the humps. No visit took 1,234 ms. The 75th percentile doesn't slide. It jumps:

  • With 10 or 20 first visits in every 100, the 75th visit is a regular: 808 ms.
  • With 30 or 40, the 75th visit is a first visit: 2,228 ms.

Somewhere between 20 and 30, it jumps straight from one hump to the other. It never takes a value in between.

Why it matters: a small change in who visits can move the 75th percentile by 1,420 ms, with no change to the page at all. An advert that brings in new visitors can do it.

The cache isn't the only thing that splits visits in two. Read each row across: a cause, and why it splits the visits.

Other reasons visits split in two
CauseWhy visits split
Saved files or notA regular's phone has the files in its cache. A first visit downloads everything.
The Back buttonThe back/forward cache shows the page again from memory, almost at once.
Prerendered pagesChrome can load a page before the click. Its LCP can then be close to 0.
A cache on the serverA page the server or a CDN has ready can come back fast. One it has to build waits longer.
Signed in or notA signed-in visitor can get a page made just for them, with different content.
A/B testsTwo versions of a page, shown to different visitors, can load at different speeds.

When you see two humps, don't sum them up in one number. Split the visits by the cause, and look at each group on its own. To make two humps yourself, choose Half from your cache in the first playground.

Why fixing the slowest users moves the 75th percentile

Back to Ravi. This morning's 75th percentile was 2,228 ms. He has time for one fix this month. Which visits should he speed up?

Think of the line again. The 75th percentile is whoever stands at place 15. There are only two ways to change that number:

  • make the visit at place 15 faster;
  • make visits behind it fast enough to overtake it.

Speeding up visits in front of place 15 changes nothing: they stay in front. Here are two fixes Ravi could try, with made-up savings:

Which fix moves the 75th percentile?Pop out in the code editor (opens in a new tab)JavaScript
const visits = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,  2228, 2228, 2228, 2228, 7628, 7628];const p75 = (list) => [...list].sort((a, b) => a - b)[14]; // the 15th of 20, fastest first const fastFaster = visits.map((ms) => (ms < 1000 ? ms - 300 : ms));const firstVisitsFaster = visits.map((ms) => (ms === 2228 ? ms - 1000 : ms)); console.log(`Before: ${p75(visits)} ms`);console.log(`Fast visits 300 ms faster: ${p75(fastFaster)} ms`);console.log(`First visits 1 s faster: ${p75(firstVisitsFaster)} ms`);
  • Lines 1 and 2 are the morning's visits.
  • Line 3 sorts a list, fastest first, and takes place 15. A list counts from 0, so that's [14].
  • Line 5 is the first fix. map makes a new list by changing each visit with a rule. The rule ms < 1000 ? ms - 300 : ms means: if the visit took under 1,000 ms, take 300 off; if not, leave it as it is. So the new phones and the regulars get 300 ms faster.
  • Line 6 is the second fix: each first visit, at 2,228 ms, gets 1,000 ms faster.
  • Lines 8 to 10 print Before: 2228 ms, Fast visits 300 ms faster: 2228 ms and First visits 1 s faster: 1228 ms.
The morning's twenty visits on a time line from 0 to 8 seconds, in three rows, one stack of visits per kind of visitor. Before: 75th percentile 2,228 ms. Fast visits faster: 75th percentile 2,228 ms. First visits faster: 75th percentile 1,228 ms.BeforeAsha75th: 2,228 msFast visits fasterAsha75th: 2,228 msFirst visits fasterAsha75th: 1,228 ms0 s4 s8 s
Each stack is one kind of visitor: the taller, the more visits. Shaded stacks are the visits a fix changed. Speeding up the fast visits left the 75th percentile at 2,228 ms. Speeding up the first visits moved it to 1,228 ms.

The first fix sped up fourteen visits, and the 75th percentile didn't move. All fourteen were already in front of place 15. The second fix sped up only four visits, and the 75th percentile dropped by a whole second. The first visits were standing at place 15.

Now look at the average. The first fix lowered it by 210 ms, and the second by only 200 ms. The average rewards speeding up anyone. The 75th percentile only rewards speeding up the visits at it and behind it: the slowest quarter. Why it matters: before you choose a fix, find out which visitors stand at and behind the 75th percentile in your own data. Lesson 12.5, Segmenting field data, shows how.

Now try fixes on a bigger, smoother set of visits. These 1,000 are made up, with a long tail like real data:

Playground: which fix moves the 75th percentile?
What each fix does, shortenedJavaScript
const fixes = {  fastestHalf: (ms) => (ms <= median ? ms * 0.7 : ms),  slowestQuarter: (ms) => (ms >= p75 ? ms * 0.7 : ms),  everyone: (ms) => ms * 0.9,  slowestFive: (ms) => (ms >= p95 ? Math.min(ms, 4000) : ms),}; const after = lcps.map(fixes[chosen]);
Average2,169 ms, unchanged
Median1,888 ms, unchanged
75th percentile2,627 ms, unchanged
95th percentile4,405 ms, unchanged
No fix73% good
Average
2,169 ms
Median (50th percentile)
1,888 ms
75th percentile
2,627 ms
95th percentile
4,405 ms
Try it yourself
What moved

1,000 made-up visits, and 73% of them are good. The 75th percentile is 2,627 ms, over the 2,500 ms limit. Pick a fix, and watch which numbers move.

1,000 made-up LCPs with a long tail, the same every time. Each fix changes some of them. The chart shows the visits after the fix.

The panel's code lists four fixes:

  • Line 2 speeds up the fastest half: every visit at or below the median.
  • Line 3 speeds up the slowest quarter: every visit at or above the 75th percentile.
  • Line 4 speeds up everyone a little.
  • Line 5 cuts only the slowest 1 in 20 down to 4 seconds.

Try each one, and watch the four numbers. Speeding up the fastest half lowers the average and the median, but the 75th percentile stays where it was. One of the other fixes moves the 95th percentile but leaves the median and the 75th alone. Can you find it?

Segmenting before you conclude

A month later, Ravi has thousands of visits. He compares October with November, and finds something odd. Phones got faster. Computers got faster. Yet all the visits together got slower. How can that be?

Real-life analogyEvery delivery got quicker, but the average got slower

A tea shop delivers by bike in town and by van to the villages. Bike trips are short, and van trips are long. This month, both got quicker. Yet the shop's average delivery took longer. Most of this month's orders came from the villages, so more of the trips were long van trips.

In real life: Bike deliveries in town
In web performance: Visits from computers
In real life: Van deliveries to the villages
In web performance: Visits from phones, the slower group
In real life: More orders from the villages
In web performance: More of the visits coming from phones
In real life: The shop's average delivery time
In web performance: The 75th percentile of all visits

A segment is one group of visits that share something, such as phones, one country, or first visits. Splitting visits into segments is called segmenting. Here are two small, made-up months, split into phones and computers:

Every group faster, the total slowerPop out in the code editor (opens in a new tab)JavaScript
const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(0.75 * values.length) - 1]; const months = {  october: { phones: [2000, 2400, 2800, 3200], computers: [800, 900, 1000, 1100, 1200, 1300, 1400, 1500] },  november: { phones: [1800, 1900, 2100, 2200, 2500, 2600, 2900, 3000], computers: [700, 800, 900, 1000] },}; for (const [month, { phones, computers }] of Object.entries(months)) {  console.log(month, "phones:", p75(phones), "computers:", p75(computers), "all:", p75([...phones, ...computers]));}
  • Line 1 is the one-line percentile function from before.
  • Line 4 is October: 4 visits from phones and 8 from computers. A third came from phones.
  • Line 5 is November. After an advert for the shop, say, 8 came from phones and 4 from computers. That's two thirds from phones.
  • Line 8 goes through the two months. Line 9 prints each group's 75th percentile, then all the visits together.
  • It prints october phones: 2800 computers: 1300 all: 2000 and november phones: 2600 computers: 900 all: 2500.
Two months of visits as bars split into phones and computers. October: 4 phone visits with a 75th percentile of 2,800 ms, 8 computer visits at 1,300 ms, and 2,000 ms for all visits. November: 8 phone visits with a 75th percentile of 2,600 ms, 4 computer visits at 900 ms, and 2,500 ms for all visits.Octoberphonescomputers75th: 2.8 s75th: 1.3 sall: 2.0 sNovemberphonescomputers75th: 2.6 s75th: 0.9 sall: 2.5 s
Each bar is one month of visits. Both groups got faster from October to November. But phones, the slower group, went from a third of the visits to two thirds. So all the visits together got slower.

Phones got faster: 2,800 ms became 2,600 ms. Computers got faster: 1,300 ms became 900 ms. But all the visits together got slower: 2,000 ms became 2,500 ms. Nothing on the page got worse. The mix changed: more of the visits came from the slower group.

This is called Simpson's paradox. A trend inside every group can disappear, or even turn around, when the groups are put together. Why it matters: if Ravi watched only the total, he'd hunt for a slowdown that never happened. So:

  • compare each segment with itself, month by month;
  • keep an eye on each segment's share of the visits.

These segments are often worth splitting by. Read each row across: the segment, and why it matters.

Segments worth splitting by
SegmentWhy it matters
Device typePhones and computers can differ a lot in speed. Their share of visits can change with a sale or a season.
ConnectionA slow network stretches every download. In Chromium browsers, navigator.connection gives a rough guess of it.
CountryThe distance to your servers, and the phones and networks people have, change from country to country.
Navigation typeA first load, a reload, a Back-button visit and a prerendered page each load in their own way.
New or returningA returning visitor's browser may have the files saved already.
Page typeA home page, a product page and a checkout are different pages, with different problems.

The trap works the other way too. In lesson 1.3, all the loads together passed at the 75th percentile, while the phones alone failed. The fast computers hid them.

Module 12 does all of this with real visits. Lesson 12.4, Building a RUM pipeline, works out percentiles from the numbers visitors send home. Lesson 12.5 splits them into segments and catches Simpson's paradox in real data.

Which number answers it?

Each question below needs a different way of reading the data. Sort each one by the number or picture that answers it:

Which number answers it?
  • What does a typical visit feel like?
  • Half of all visits were faster than what?
  • Does the page pass the Core Web Vitals?
  • Do at least three in four visits get a good LCP?
  • How long do the unluckiest 1 in 20 visits wait?
  • Has the slow tail got longer since last month?
  • Are cached and uncached visits two separate groups?
  • Where do visits bunch up, and how long is the tail?
Try it yourself
0 of 8 correct

Sort each question by the number or chart that answers it.

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

Common mix-ups

  • "The average is what a typical visitor gets." No. A long tail pulls it up. This morning, no visitor got their teas at the average. The median is the typical visit.
  • "A good 75th percentile means nobody had a bad time." No. Up to 1 visit in 4 can be slower. The morning passed at 2,228 ms while Asha waited 7,628 ms. Look at the 95th percentile and the histogram too.
  • "I can average my pages' 75th percentiles to get the site's." No. Put the visits together first, then take the percentile.
  • "Any speed-up improves the 75th percentile." No. Only a fix that speeds up the visit at it, or helps visits behind it overtake it, moves it.
  • "If every segment got faster, the total got faster." No. If more visits come from a slower segment, the total can get slower.
  • "Narrower bins always show more." No. With only a few visits, narrow bins show noise. Bins that are too wide hide the shape.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upWide jars

Ravi tries jars four seconds wide. How many # does the first line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const visits = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,
  2228, 2228, 2228, 2228, 7628, 7628];

const jars = [0, 0]; // 0-4 s and 4-8 s
for (const ms of visits) {
  const jar = Math.floor(ms / 4000);
  jars[jar] = jars[jar] + 1;
}

jars.forEach((count, jar) => console.log(`${jar * 4}-${(jar + 1) * 4} s |${"#".repeat(count)}`));

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

    Exercise 2 · Warm-upFixing Asha's visits

    This fix makes only Asha's two visits 3 seconds faster. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const visits = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,
      2228, 2228, 2228, 2228, 7628, 7628];
    const p75 = (list) => [...list].sort((a, b) => a - b)[14]; // the 15th of 20, fastest first
    
    const ashaFaster = visits.map((ms) => (ms === 7628 ? ms - 3000 : ms));
    
    console.log(`Before: ${p75(visits)} ms`);
    console.log(`Asha's visits 3 s faster: ${p75(ashaFaster)} ms`);

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

      Exercise 3 · PracticeExactly a quarter

      Exactly a quarter of the visits are first visits. What 75th percentile does it print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const returning = 808;
      const firstVisit = 2228;
      
      for (const share of [25]) {
        const visits = [];
        for (let i = 0; i < 100; i++) visits.push(i < 100 - share ? returning : firstVisit);
        const mean = visits.reduce((sum, ms) => sum + ms, 0) / visits.length;
        console.log(`${share}% first visits: mean ${Math.round(mean)} ms, p75 ${visits[74]} ms`);
      }

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

        Exercise 4 · PracticePhones fall back

        In December, phones are back to a third of the visits. What does the december line print for all?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(0.75 * values.length) - 1];
        
        const months = {
          october: { phones: [2000, 2400, 2800, 3200], computers: [800, 900, 1000, 1100, 1200, 1300, 1400, 1500] },
          december: { phones: [1800, 2100, 2500, 2900], computers: [700, 800, 900, 1000, 1100, 1200, 1300, 1400] },
        };
        
        for (const [month, { phones, computers }] of Object.entries(months)) {
          console.log(month, "phones:", p75(phones), "computers:", p75(computers), "all:", p75([...phones, ...computers]));
        }

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

          Exercise 5 · ChallengeThe cache in the Network panel

          Open DevTools' Network panel, then run the first playground with Half from your cache. Which column tells you a request came from the cache?

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

            Exercise 6 · ChallengeEvery page got faster

            A shop's home, product and checkout pages all have a lower 75th percentile than last month. But the whole site's is higher. What changed?

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

              Check your understanding

              For each question, ask two things: what shape is the data, and which visits does this number depend on?

              The reading distributions quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the third line of the jar example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const visits = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,
                  2228, 2228, 2228, 2228, 7628, 7628];
                
                const jars = [0, 0, 0, 0, 0, 0, 0, 0]; // 0-1 s, 1-2 s, 2-3 s, and so on, up to 7-8 s
                for (const ms of visits) {
                  const jar = Math.floor(ms / 1000);
                  jars[jar] = jars[jar] + 1;
                }
                
                jars.forEach((count, jar) => console.log(`${jar}-${jar + 1} s |${"#".repeat(count)}`));

                Choose an answer to see the explanation.

              2. Question 2 of 7In page load times with a long tail, why does the average usually come out above the median?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does the last line of the averaged percentiles example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(0.75 * values.length) - 1];
                
                const home = [900, 1000, 1100, 1200];
                const product = [1500, 2000, 2600, 3100, 3500, 4000, 4400, 5200];
                
                console.log("home:", p75(home));
                console.log("product:", p75(product));
                console.log("average of the two:", (p75(home) + p75(product)) / 2);
                console.log("p75 of all visits:", p75([...home, ...product]));

                Choose an answer to see the explanation.

              4. Question 4 of 7With the shop's recorded regulars and first visits, why does the percentile the Core Web Vitals use jump, instead of sliding, as first visits grow?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does the last line of the fix example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const visits = [564, 564, 564, 564, 564, 564, 808, 808, 808, 808, 808, 808, 808, 808,
                  2228, 2228, 2228, 2228, 7628, 7628];
                const p75 = (list) => [...list].sort((a, b) => a - b)[14]; // the 15th of 20, fastest first
                
                const fastFaster = visits.map((ms) => (ms < 1000 ? ms - 300 : ms));
                const firstVisitsFaster = visits.map((ms) => (ms === 2228 ? ms - 1000 : ms));
                
                console.log(`Before: ${p75(visits)} ms`);
                console.log(`Fast visits 300 ms faster: ${p75(fastFaster)} ms`);
                console.log(`First visits 1 s faster: ${p75(firstVisitsFaster)} ms`);

                Choose an answer to see the explanation.

              6. Question 6 of 7Phones and computers both got faster in November, but the 75th percentile of all visits got slower. How?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why did web.dev choose the 75th percentile for the Core Web Vitals, rather than the 95th?

                Choose an answer to see the explanation.

              Key takeaways

              • Field data is a distribution: many numbers with a shape. Look at the shape in a histogram before you sum it up.
              • Page load times often have a long tail of slow visits. It pulls the average up, so the average can describe nobody.
              • The median is the typical visit. The 75th percentile is the Core Web Vitals' bar. The 95th watches the slow end.
              • Never average percentiles. Put the visits together first.
              • Two humps mean two groups, such as cached and uncached visits. Split them, and expect the 75th percentile to jump between them.
              • Only fixes that reach the visits at or behind the 75th percentile move it. Work on the slow visits.
              • Segment before you decide anything. A change in the mix of visits can move the total on its own.

              Remember the one-liner.
              An average can describe nobody: look at the shape, point at real visits, and split the groups before you decide.

              Sources

              The facts on this page come from these docs, references and source code:

              Coming next: Latency vs bandwidth, the first lesson of How the browser loads and renders. It goes back to the very start of a page load, the network. You'll see why round trips, not megabits, decide how fast most pages load.

              CompleteFrontend Clear concepts. Working examples.