completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Field data from every browser

Firefox and Safari now report LCP and INP. Measure every engine, and know what Chrome-only data leaves out. Forty visits to a tea shop in Chrome, Firefox, WebKit and WebKit dressed as an iPhone, with every metric web-vitals sent.

By the end, you can
  • 01
    Know who can measure whatSay which of TTFB, FCP, LCP, INP and CLS each browser can report, and check before you measure.
  • 02
    Know whose visits CrUX missesExplain why Firefox, Safari and every iPhone browser are missing from CrUX, and from any Chrome-only data.
  • 03
    Compare browsers fairlyLabel each beacon with its engine, count a missing metric as no answer, and compare each engine with itself.

Your users don't all use Chrome

Ravi orders his mint tea ahead, on his iPhone, in Chrome. Asha orders hers in Chrome on her Android phone. Say the tea shop is busy enough to have a report in CrUX, the Chrome User Experience Report. Its numbers look good. But whose visits are in them?

Asha's can be. Ravi's can't. CrUX never hears from Chrome on an iPhone, and it never hears from Safari or Firefox at all. So you put the web-vitals library on the page yourself. Now every visit sends its numbers home in a beacon: a small message the page posts to your server, whatever the browser.

The course recorded four kinds of visitor. Here is who hears from each:

The tea shop's visitors, as the course recorded them. Chrome 156 on a laptop, engine Blink, 10 visits: CrUX can hear from them. Firefox 155 on a laptop, engine Gecko, 10 visits: CrUX never hears from them. WebKit 26.6 on a laptop, engine WebKit, 10 visits: CrUX never hears from them. iPhone 17 like Ravi's, engine WebKit, 10 visits: CrUX never hears from them. CrUX hears from at most 10 of 40 visits. Your own web-vitals beacons reach your server from 40 of 40.CrUX: at most 10 of 40 visitsCrUXChrome 156on a laptopBlink10 visitsCrUXFirefox 155on a laptopGecko10 visitsCrUXWebKit 26.6on a laptopWebKit10 visitsCrUXiPhone 17like Ravi'sWebKit10 visitsyour server: 40 of 40 visits
Each window is one kind of visitor the course recorded, with the engine its browser runs. Shaded: the one whose visits can reach CrUX, as Asha's can. Crossed out: the ones it never hears from, Ravi's iPhone among them. Your web-vitals beacons reach your server from all of them.

CrUX can hear from Chrome's visits only. Your beacons come home from all four, Ravi's iPhone too.

That leaves you with two questions:

  • Can Ravi's browser measure his visit at all?
  • If it can, do its numbers mean the same as Chrome's?

Every browser has an engine: the part that turns a page's HTML, CSS and scripts into what you see. Chrome's engine is Blink. Firefox's is Gecko. Safari's is WebKit. The course loaded the same tea shop page in Chrome on a laptop, and in WebKit dressed as an iPhone, like Ravi's. Here is what the first visit in each sent home:

What two visits sent homePop out in the code editor (opens in a new tab)JavaScript
// Recorded: what the first visit in two groups sent home. LCP and INP are in ms.const sent = {  chrome: { LCP: 344, INP: 200, CLS: 0.0468 },  iphone: { LCP: 340, INP: 152 },}; for (const [group, metrics] of Object.entries(sent)) {  const parts = Object.entries(metrics).map(([name, value]) => `${name} ${value}`);  console.log(`${group} sent ${parts.join(", ")}`);}
  • Line 1 is a comment. It says two of the numbers are times in milliseconds (ms), thousandths of a second.
  • Line 3 is recorded from Chrome's first visit. Line 4 is recorded from the first visit dressed as an iPhone.
  • Lines 7 to 10 print one sentence per visit: chrome sent LCP 344, INP 200, CLS 0.0468 and iphone sent LCP 340, INP 152.

Those three names are the Core Web Vitals:

  • LCP, Largest Contentful Paint: when the biggest image or block of text appeared.
  • INP, Interaction to Next Paint: how long the page took to show the result of a click, tap or key press.
  • CLS, Cumulative Layout Shift: a score for how far things on the page jumped. 0 means nothing moved.

Two things stand out:

  • The iPhone visit sent LCP and INP. So Ravi's browser can measure. But it sent no CLS.
  • Both visits pressed the same Add to cart button, which keeps the page busy for 150 ms. Chrome said that press took 200 ms. WebKit said 152 ms.

Is a missing CLS a problem? Is 200 worse than 152? To answer, the course made 10 visits in each of four groups, 40 in all, on one laptop:

  • Chrome 156.
  • Firefox 155.0.
  • WebKit 26.6, from Playwright, a tool that drives real browsers from a script.
  • WebKit as an iPhone: Playwright's iPhone 17 settings give the page an iPhone's screen size, touch, and user agent. A user agent is the line of text that tells a site which browser it's talking to.

Each visit did what a customer does. It loaded the shop. The mint tea photo arrived 300 ms late, and at 600 ms a promo pushed the page down. Then the visit clicked Add to cart, or tapped it as an iPhone, and left for the next page. web-vitals sent a beacon for every metric it could measure.

Here are all 40 visits. Pick a group and a metric. Each bar is one visit:

Playground: field data, split by engine
VisitsLCP
  1. Chrome 1344 ms
  2. Chrome 2328 ms
  3. Chrome 3320 ms
  4. Chrome 4336 ms
  5. Chrome 5328 ms
  6. Chrome 6320 ms
  7. Chrome 7320 ms
  8. Chrome 8324 ms
  9. Chrome 9324 ms
  10. Chrome 10324 ms
  11. Firefox 1417 ms
  12. Firefox 2333 ms
  13. Firefox 3352 ms
  14. Firefox 4336 ms
  15. Firefox 5323 ms
  16. Firefox 6334 ms
  17. Firefox 7322 ms
  18. Firefox 8338 ms
  19. Firefox 9317 ms
  20. Firefox 10336 ms
  21. WebKit 11,180 ms
  22. WebKit 2340 ms
  23. WebKit 3332 ms
  24. WebKit 4340 ms
  25. WebKit 5336 ms
  26. WebKit 6340 ms
  27. WebKit 7340 ms
  28. WebKit 8340 ms
  29. WebKit 9340 ms
  30. WebKit 10340 ms
  31. WebKit as an iPhone 1340 ms
  32. WebKit as an iPhone 2348 ms
  33. WebKit as an iPhone 3348 ms
  34. WebKit as an iPhone 4340 ms
  35. WebKit as an iPhone 5340 ms
  36. WebKit as an iPhone 6344 ms
  37. WebKit as an iPhone 7336 ms
  38. WebKit as an iPhone 8340 ms
  39. WebKit as an iPhone 9364 ms
  40. WebKit as an iPhone 10356 ms
Try it yourself
Everyone: p75 LCP 340 ms

All 40 visits sent LCP. Each bar is one visit, in the order the course made them.

The course's 40 visits, all on one laptop (Apple M1 Pro), with web-vitals 6.2.3: Chrome 156, Firefox 155.0, WebKit 26.6, and WebKit with Playwright's iPhone 17 settings.

Pick CLS, then Firefox. Every row says "no answer". Hold on to that phrase; the next section explains it.

Real-life analogyA survey for one bank's cardholders

A café's bank offers it a free customer survey. But the bank only sends the form to people who paid with its own card. The answers come back, and they look good. Everyone who paid with cash, or with another bank's card, never got the form. The café heard from one group of customers, not from all of them.

In real life: Customers who paid with that bank's card
In field data: Chrome users on a computer or on Android, who opted in
In real life: The bank's survey report
In field data: CrUX, or any number that only Chrome measured
In real life: Customers who paid some other way
In field data: Visitors on Safari, Firefox or any iPhone browser
In real life: Asking every customer at the till yourself
In field data: Your own beacons, from every browser
The main idea

Chrome's numbers describe Chrome's visitors. Firefox and Safari can now measure LCP and INP too, so collect your own data from every browser. Label each number with the engine that measured it. Count a metric a browser can't measure as no answer, not 0. And compare each engine with itself.

The next four sections take one question each:

  • Which metrics can each browser measure?
  • Why does CrUX only hear from Chrome?
  • Can you compare numbers from different engines?
  • How do you spot Safari and iPhone visits in your data?

Which metrics each engine reports

Start with Ravi's first question. Can his browser measure his visit at all?

A browser measures by writing entries on its performance timeline, which you met in The performance timeline. An entry is a small record of one thing that happened, such as a paint. Each entry has a type, and each metric is built on one entry type:

  • TTFB, Time to First Byte, is when the first byte of the page arrived. It comes from the navigation entry.
  • FCP, First Contentful Paint, is when the first text or image appeared. It comes from paint entries.
  • LCP comes from largest-contentful-paint entries.
  • INP comes from event entries that carry an interactionId, the number that ties one click's events together.
  • CLS comes from layout-shift entries.

No entry type, no metric. So which types does each browser write? Each browser keeps a list in PerformanceObserver.supportedEntryTypes. The tea shop page sent that list home on every visit. Here are the five types that matter, as each browser listed them:

Which metrics each browser can reportPop out in the code editor (opens in a new tab)JavaScript
// Recorded: which of these five entry types each browser listed in PerformanceObserver.supportedEntryTypes.const listed = {  chrome: ["navigation", "paint", "largest-contentful-paint", "event", "layout-shift"],  firefox: ["navigation", "paint", "largest-contentful-paint", "event"],  webkit: ["navigation", "paint", "largest-contentful-paint", "event"],};// The entry type each metric is built on.const needs = { TTFB: "navigation", FCP: "paint", LCP: "largest-contentful-paint", INP: "event", CLS: "layout-shift" }; for (const [browser, types] of Object.entries(listed)) {  const canReport = Object.keys(needs).filter((metric) => types.includes(needs[metric]));  console.log(`${browser} can report ${canReport.join(", ")}`);}
  • Lines 2 to 6 are recorded. Chrome's list, on line 3, has all five types. Firefox's and WebKit's, on lines 4 and 5, have four.
  • Line 8 pairs each metric with the entry type it needs.
  • Line 11 keeps the metrics whose type is on the browser's list. Line 12 prints them.
  • It prints chrome can report TTFB, FCP, LCP, INP, CLS, firefox can report TTFB, FCP, LCP, INP and webkit can report TTFB, FCP, LCP, INP.

The only gap is layout-shift. So Firefox and WebKit can measure four of the five metrics, but not CLS. The 40 visits agreed with the lists:

Which metrics each group's visits sent: Chrome: TTFB, FCP, LCP, INP, CLS; Firefox: TTFB, FCP, LCP, INP; WebKit: TTFB, FCP, LCP, INP; WebKit as an iPhone: TTFB, FCP, LCP, INP.ChromeTTFBFCPLCPINPCLSFirefoxTTFBFCPLCPINPCLSWebKitTTFBFCPLCPINPCLSWebKit as an iPhoneTTFBFCPLCPINPCLS
Each row is one group of 10 visits. A shaded card means the group sent that metric, and a crossed-out card means it never did. Only Chrome's row has CLS.

Safari added LCP and INP in version 26.2. WebKit's notes for it say: "Safari 26.2 adds support for two tools that measure the performance of web applications, Event Timing API and Largest Contentful Paint." Event Timing, the notes add, "makes measuring 'Interaction to Next Paint' (INP) possible." Firefox added LCP in version 122, and interactionId in version 144.

Each box below shows the first version of Chrome, Firefox and Safari that supports one of these:

Why it matters: with Safari 26.2's WebKit or later, Ravi's iPhone can tell you his LCP and INP. It can't tell you his CLS.

web-vitals checks the list first

What did web-vitals do about CLS in Firefox? It didn't throw an error, and it didn't send 0. It sent nothing at all, because it checks the list before it measures.

Real-life analogyChecking the card machine before offering contactless

A café gets a new card machine. Before the cashier says "just tap your card", she checks that the machine takes contactless. If it doesn't, she doesn't wave cards at it and hope. She just doesn't offer it.

In real life: The café's card machine
In the browser: The browser
In real life: The list of payments the machine takes
In the browser: PerformanceObserver.supportedEntryTypes
In real life: Offering contactless only if it's on the list
In the browser: Starting an observer only for entry types on the list
In real life: No contactless, and no fuss
In the browser: No CLS from Firefox, and no error

web-vitals makes that check in one small function, observe(), which every metric uses. An observer is the object that gets told about new entries of a type. Here is the function, shortened:

web-vitals checks the list first, shortened from observe.tsJavaScript
// Shortened from web-vitals 6.2.3, src/lib/observe.tsconst supportedTypes = types.filter((t) =>  PerformanceObserver.supportedEntryTypes.includes(t),);if (supportedTypes.length > 0) {  const po = new PerformanceObserver((list) => { /* hand the entries to the metric */ });  for (const t of supportedTypes) {    po.observe({type: t, buffered: true, ...opts});  }  return po;}return;
  • Line 1 says where the code comes from.
  • On line 2, types holds the entry types a metric asked for. For CLS, that's layout-shift. Lines 2 to 4 keep only the types on this browser's list.
  • Line 5 checks whether any are left. If so, lines 6 to 10 make an observer, watch each type, and hand the observer back.
  • If none are left, line 12 hands back nothing.

In Firefox, layout-shift isn't on the list, so line 12 runs. And onCLS only sets up its report when it gets an observer back. So in Firefox and Safari, your CLS callback is never called.

The library's README says the same in its list of browser support. onCLS() works in "Chromium", the open-source project Chrome is built from. onFCP(), onINP(), onLCP() and onTTFB() work in "Chromium, Firefox, Safari".

Why it matters: an observer for a type the browser doesn't write just never hears anything, as The performance timeline showed. Then "no entries" could mean "nothing moved" or "can't measure". Check the list first, as web-vitals does, and you know which.

A missing CLS is no answer, not 0

So 30 of the 40 visits sent no CLS. When you add up your data, what do you write down for them? It's tempting to write 0. Don't.

Real-life analogyA question some customers can't answer

The tea shop's survey asks, "How often did your table wobble?" Customers who took their tea away never sat at a table, so they can't answer. If you write 0 for them, the tables look steadier than they are. So you write "no answer", and work out the result from the people who sat down.

In real life: Customers who sat at a table
In field data: Chrome visits, which can measure layout shifts
In real life: Customers who took their tea away
In field data: Firefox and Safari visits, which can't
In real life: Writing 0 for them
In field data: Counting a missing CLS as 0
In real life: Writing "no answer"
In field data: Leaving the visit out of CLS

CLS makes this mistake easy, because 0 is its best score. Here is the tea shop's CLS worked out both ways. It uses the p75, or 75th percentile, the number the Core Web Vitals are judged by. Line up the values from smallest to largest, and take the one three quarters of the way along:

No answer, or 0?Pop out in the code editor (opens in a new tab)JavaScript
// Recorded: all 10 Chrome visits sent the same CLS. The other 30 visits sent none.const chromeCls = Array(10).fill(0.0468);const noAnswer = 30; // The 75th percentile by nearest rank.const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1]; const asZero = [...chromeCls, ...Array(noAnswer).fill(0)];console.log(`Counting "no answer" as 0: p75 CLS ${p75(asZero)}`);console.log(`Leaving "no answer" out: p75 CLS ${p75(chromeCls)}`);
  • Line 2 is recorded: all 10 Chrome visits sent the same CLS, 0.0468. The promo pushed the page down the same way every time.
  • Line 3 counts the 30 visits that sent no CLS.
  • Line 6 works out the p75: it sorts the values, then takes the one three quarters of the way along.
  • Line 8 fills the 30 gaps with 0. Line 9 prints Counting "no answer" as 0: p75 CLS 0.
  • Line 10 uses Chrome's values alone. It prints Leaving "no answer" out: p75 CLS 0.0468.

The first line reports a perfect CLS for a page that shifted on every visit that could measure it. Thirty zeros fill the bottom three quarters of the line, so the p75 lands on a zero. The second line is honest: 0.0468, from Chrome's visits.

Read this table across. Each row is one thing a visit can send, and what it really tells you:

Zero or no answer?
What a visit sentWhat it tells you
CLS 0.0468 from ChromeChrome watched for layout shifts. It saw the promo push the page down.
CLS 0 from ChromeChrome watched for layout shifts and saw none. Nothing moved.
No CLS from Firefox or SafariThe browser can't watch for layout shifts. Nobody knows whether the page moved.

Why it matters: leave out a missing metric before you work out a p75. Then say whose visits the number covers: "p75 CLS 0.0468, Chrome visits only". As The performance timeline put it, report a missing type as missing, not as 0.

Why CrUX only sees Chrome

Now back to the survey from the first section. Why does CrUX hear only from Chrome? Because Chrome itself sends the reports. In The Chrome User Experience Report, CrUX was a survey of people who signed up. Its methodology page says who that is:

  • Counted: people on Chrome for Windows, macOS, ChromeOS or Linux, and people on Chrome for Android.
  • Only if they opted in: they turned on two Chrome settings, usage statistics reporting and history sync. And they set no Sync passphrase.
  • Left out by name: "Chrome on iOS. Android apps using WebView. Other Chromium browsers (for example Microsoft Edge)."
  • Never counted: Firefox and Safari, because they aren't Chrome.

Put the course's 40 visits through that filter:

Who CrUX could hear fromPop out in the code editor (opens in a new tab)JavaScript
// The 40 recorded visits, by group.const visits = { chrome: 10, firefox: 10, webkit: 10, iphone: 10 };const total = visits.chrome + visits.firefox + visits.webkit + visits.iphone; // CrUX only hears from Chrome on a computer or on Android.console.log(`CrUX could hear from at most ${visits.chrome} of ${total} visits.`);
  • Line 2 counts the recorded visits in each group.
  • Line 3 adds them up: 40.
  • Line 6 keeps Chrome's visits only. It prints CrUX could hear from at most 10 of 40 visits.

Why "at most"? Only Chrome users who opted in are counted, and nobody outside Google knows how many that is. The methodology says: "Chrome does not publish data about the proportions of users that meet these criteria."

Ravi's visits are left out too, even though he uses Chrome. Chrome on iOS is the first exception on the list.

Why it matters: if many of your customers order on iPhones, CrUX speaks for few of them. web-vitals on your own page hears from all of them. That's the café asking every customer at the till.

One gap stays, even in your own data. Only Chrome can measure CLS, so your CLS comes from Chrome's visitors, just like CrUX's.

Comparing engines in your own data

Now you have beacons from every browser. That brings back Ravi's second question: do their numbers mean the same thing? Can you pour them all into one pile?

Everyone, or Chrome alone?

Start with LCP. Here is the p75 of all 40 visits. Then comes the p75 of Chrome's 10 alone, the view a Chrome-only tool would give you:

Everyone's LCP, and Chrome'sPop out in the code editor (opens in a new tab)JavaScript
// Recorded: LCP from the 40 visits, in ms, labelled by group.const lcp = {  chrome: [344, 328, 320, 336, 328, 320, 320, 324, 324, 324],  firefox: [417, 333, 352, 336, 323, 334, 322, 338, 317, 336],  webkit: [1180, 340, 332, 340, 336, 340, 340, 340, 340, 340],  iphone: [340, 348, 348, 340, 340, 344, 336, 340, 364, 356],};const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1]; console.log(`Everyone: p75 LCP ${p75(Object.values(lcp).flat())} ms`);console.log(`Chrome only: p75 LCP ${p75(lcp.chrome)} ms`);
  • Lines 2 to 7 are recorded: every visit's LCP in ms, one line per group, in the order the course made them.
  • Line 8 is the same p75 as before.
  • Line 10 pools all four groups. It prints Everyone: p75 LCP 340 ms.
  • Line 11 uses Chrome's group alone. It prints Chrome only: p75 LCP 328 ms.

Chrome's visits were a little quicker than the rest here. So the Chrome-only number made the shop look a little better than it was for everyone. That's the bank's survey again: right about its own customers, but not about the whole café.

Each engine is its own thermometer

Now INP. Here, the engines themselves disagree. Think of thermometers:

Real-life analogyThree thermometers from three makers

You hang three thermometers from three makers in the same room. One reads 21 degrees, one 21.5, and one 22. None of them is broken; each maker measures a little differently. So in your notebook, you write down which thermometer took each reading. And you compare each one with its own earlier readings.

In real life: Three makers' thermometers
In field data: Chrome, Firefox and WebKit
In real life: The same room
In field data: The same page, the same laptop, the same click
In real life: Writing down which thermometer took each reading
In field data: Sending the engine with every beacon
In real life: Comparing a thermometer with its own earlier readings
In field data: Comparing Chrome's p75 with Chrome's p75 last month

This time, work out a p75 for each group on its own:

INP, one group at a timePop out in the code editor (opens in a new tab)JavaScript
// Recorded: INP from the 40 visits, in ms, labelled by group.const inp = {  chrome: [200, 184, 192, 184, 184, 184, 192, 192, 184, 184],  firefox: [160, 160, 160, 152, 160, 160, 152, 160, 160, 152],  webkit: [152, 152, 152, 152, 152, 152, 152, 152, 152, 152],  iphone: [152, 152, 152, 152, 152, 152, 152, 152, 152, 152],};const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1]; for (const [group, values] of Object.entries(inp)) console.log(`${group}: p75 INP ${p75(values)} ms`);console.log(`everyone: p75 INP ${p75(Object.values(inp).flat())} ms`);
  • Lines 2 to 7 are recorded: every visit's INP in ms, one line per group.
  • Line 10 prints one p75 per group: chrome: p75 INP 192 ms, firefox: p75 INP 160 ms, webkit: p75 INP 152 ms and iphone: p75 INP 152 ms.
  • Line 11 pools them all. It prints everyone: p75 INP 160 ms.

Every visit pressed the same button on the same laptop, and every press did the same 150 ms of work. Yet Chrome put its p75 at 192 ms, and WebKit at 152 ms. This time, Chrome alone made the shop look worse than everyone's 160 ms. So a Chrome-only number can be too good on one metric and too bad on another.

Why do the engines disagree? From these numbers alone, you can't tell a page that really answered later from a clock that stopped later. The standards describe the same moments, but leave each browser some room in how it notes the time:

  • Paint Timing asks for "the latest timestamp the user agent is able to note", as a "best effort". A standard's "user agent" is the browser itself.
  • In Paint, LCP, and layout shift APIs, Chrome's first paint included the last step to the screen. Firefox's stopped before it.
  • Every INP here is a multiple of 8. Event Timing rounds each duration "to the nearest 8ms", as Event Timing and INP showed.

Why it matters: send the engine with every beacon, and compare each engine with itself. If Chrome's p75 INP drops next month, compare it with Chrome's p75 this month, not with WebKit's.

One slow first visit

The picture shows every visit's LCP, one row per group:

LCP of every visit, by group: Chrome 344, 328, 320, 336, 328, 320, 320, 324, 324, 324 ms, p75 328 ms; Firefox 417, 333, 352, 336, 323, 334, 322, 338, 317, 336 ms, p75 338 ms; WebKit 1180, 340, 332, 340, 336, 340, 340, 340, 340, 340 ms, p75 340 ms; WebKit as an iPhone 340, 348, 348, 340, 340, 344, 336, 340, 364, 356 ms, p75 348 ms.Chromep75 328 msFirefoxp75 338 ms417WebKitp75 340 ms1180WebKit as an iPhonep75 348 ms310 ms370 ms
Each thin mark is one visit's LCP, and the tall shaded mark is the group's p75. Visits slower than 370 ms sit at the right edge, with their time written beside them.

Each group's first visit had its slowest first paint. WebKit's was the slowest of all: FCP 1164 ms and LCP 1180 ms. Its other nine visits had an LCP of 332 to 340 ms. Yet WebKit's p75 LCP stayed at 340 ms. One slow visit in ten sits in the slowest quarter, and the p75 doesn't look there.

Why it matters: a p75 can hide one bad visit in ten, so look at the slowest visits too.

Fields only Chrome has

Chrome also has two fields that help you split its own visits:

  • navigator.connection.effectiveType is Chrome's guess at the kind of network, such as 4g. It said 4g.
  • navigator.deviceMemory is about how much memory the device has, in gigabytes. It said 16.

Firefox and WebKit gave no answer for either. Treat them the way you treat CLS: as no answer, never as 0.

Safari and iOS users

Back to Ravi. He uses Chrome. So why did this lesson put his visits with Safari's, in a group called WebKit as an iPhone?

Because on an iPhone, Apple decides the engine. Its App Review Guidelines, rule 2.5.6, say: "Apps that browse the web must use the appropriate WebKit framework and WebKit JavaScript." The rule goes on: "You may apply for an entitlement to use an alternative web browser engine in your app." An entitlement is a permission Apple grants an app. Apple offers this one in the EU and Japan.

Real-life analogyThree shop signs, one kitchen

On a food street, three tea stalls stand side by side, each with its own sign. Walk round the back, and you find they share one kitchen. Whichever stall you order from, the same cooks make your tea, the same way.

In real life: The three stalls' signs
In iOS: Chrome, Firefox and Safari on an iPhone
In real life: The shared kitchen
In iOS: WebKit
In real life: What the kitchen can make
In iOS: What any iPhone browser built on WebKit can measure: LCP and INP, but no CLS
In real life: The sign over the stall you ordered from
In iOS: The browser's name in its user agent
On a Mac, Chrome runs Blink, Firefox runs Gecko and Safari runs WebKit. On an iPhone, under Apple's guideline 2.5.6, Chrome, Firefox and Safari all run WebKit.on a MacChromeBlinkFirefoxGeckoSafariWebKitthree browsers, three engineson an iPhoneChromeFirefoxSafariWebKitthree browsers, one engine
On a Mac, each browser brings its own engine. On an iPhone, Apple's guideline 2.5.6 has browser apps use WebKit, unless Apple has given the app an entitlement for another engine. So Ravi's Chrome measures like Safari.

So Ravi's Chrome measures like Safari. It reports LCP and INP, and no CLS. And CrUX doesn't count it, because Chrome on iOS is one of its exceptions.

Which engine sent this beacon?

To split your data by engine, each beacon needs a label. The usual one is the user agent. The browser sends it with every request, and shows it to scripts as navigator.userAgent. The tea shop page sent it home. But a user agent names more than its own browser:

Chrome's user agent names other browsersPop out in the code editor (opens in a new tab)JavaScript
// Recorded: the user agent Chrome sent.const ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36"; const names = ["Chrome/", "Safari/", "AppleWebKit/", "Gecko"].filter((name) => ua.includes(name));console.log(`Chrome's user agent mentions ${names.join(", ")}`);
  • Line 2 is recorded: the user agent Chrome sent.
  • Line 4 checks four names, and keeps the ones that appear in it.
  • Line 5 prints Chrome's user agent mentions Chrome/, Safari/, AppleWebKit/, Gecko.

So a check for "Safari" alone would call this Chrome visit a Safari visit. Put the most specific checks first:

Which engine sent this?Pop out in the code editor (opens in a new tab)JavaScript
// Recorded: the user agent each group sent.const agents = {  chrome: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",  firefox: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:155.0) Gecko/20100101 Firefox/155.0",  webkit: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Safari/605.1.15",  iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1",}; // Order matters: three of them say "Safari", and Chrome's says "AppleWebKit" too.function engine(ua) {  if (/iPhone|iPad/.test(ua)) return "WebKit on iOS";  if (/Firefox\//.test(ua)) return "Gecko";  if (/Chrome\//.test(ua)) return "Blink";  if (/Version\/[\d.]+.*Safari\//.test(ua)) return "WebKit on macOS";  return "unknown";}for (const [group, ua] of Object.entries(agents)) console.log(`${group}: ${engine(ua)}`);
  • Lines 2 to 7 are recorded: the user agent each group sent.
  • Line 11 looks for an iPhone or iPad first. On those, browsers run WebKit, whatever their name. The rare exception is an app with Apple's entitlement, in the EU or Japan.
  • Line 12 looks for Firefox/. Line 13 looks for Chrome/, before any check for Safari.
  • Line 14 only calls a visit WebKit on macOS if it says Version/ and Safari/, and nothing earlier matched.
  • Line 17 prints chrome: Blink, firefox: Gecko, webkit: WebKit on macOS and iphone: WebKit on iOS.

Read the recorded strings closely, and three more things stand out:

  • Chrome's says HeadlessChrome. The course ran Chrome without a window, from a script. Visits like that are robots and tests, not customers, so leave them out of your field data.
  • The laptop has an Apple M1 Pro chip. Yet all three desktop strings say "Intel Mac OS X", version 10.15. So don't trust the system part of a user agent.
  • Chrome also has navigator.userAgentData, a tidier list of brands. It said Not:A-Brand 8, Chromium 156, Google Chrome 156, without the word Headless. Firefox and WebKit had none.

Last, a word on the iPhone group itself. It ran on the laptop, dressed as an iPhone. Its numbers show how WebKit measures, not how fast a real iPhone on a real network is. Ravi's own beacons will tell you that.

Who reports it?

Sort each metric or field by who reported it in the course's visits. For "Being counted in CrUX", ask whose visits it covers.

Who reports it?
  • LCP
  • INP
  • FCP
  • TTFB
  • CLS
  • navigator.connection.effectiveType
  • navigator.deviceMemory
  • Being counted in CrUX
Try it yourself
0 of 8 correct

Sort each one: every engine, or Chrome only.

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

Common mix-ups

  • "CrUX covers all my users." No. It covers opted-in Chrome users on computers and Android. Firefox, Safari and every iPhone browser are missing.
  • "Safari can't report Core Web Vitals." No. Since Safari 26.2, it reports LCP and INP. It doesn't report CLS.
  • "A missing CLS means the page didn't move." No. It means the browser couldn't measure it. Count it as no answer, not as 0.
  • "Chrome-only numbers are always better than everyone's." No. In the course's visits, Chrome's LCP was better than everyone's and its INP was worse.
  • "Chrome on an iPhone runs Chrome's engine." No. It runs WebKit, like every iPhone browser without Apple's entitlement.
  • "The user agent tells me the real operating system." No. On an Apple M1 Pro laptop, every desktop string said Intel Mac OS X 10.15.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upFewer visits without CLS

This shop had 10 Chrome visits and 10 iPhone visits. What does the first line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Recorded: all 10 Chrome visits sent the same CLS. The 10 iPhone visits sent none.
const chromeCls = Array(10).fill(0.0468);
const noAnswer = 10;

// The 75th percentile by nearest rank.
const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1];

const asZero = [...chromeCls, ...Array(noAnswer).fill(0)];
console.log(`Counting "no answer" as 0: p75 CLS ${p75(asZero)}`);
console.log(`Leaving "no answer" out: p75 CLS ${p75(chromeCls)}`);

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

    Exercise 2 · Warm-upSafari first

    Someone moved a check for "Safari/" to the top of the function. What does the first line print now?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Recorded: the user agent each group sent.
    const agents = {
      chrome: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",
      firefox: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:155.0) Gecko/20100101 Firefox/155.0",
      webkit: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Safari/605.1.15",
      iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1",
    };
    
    // Order matters: three of them say "Safari", and Chrome's says "AppleWebKit" too.
    function engine(ua) {
      if (/Safari\//.test(ua)) return "Safari";
      if (/iPhone|iPad/.test(ua)) return "WebKit on iOS";
      if (/Firefox\//.test(ua)) return "Gecko";
      if (/Chrome\//.test(ua)) return "Blink";
      if (/Version\/[\d.]+.*Safari\//.test(ua)) return "WebKit on macOS";
      return "unknown";
    }
    for (const [group, ua] of Object.entries(agents)) console.log(`${group}: ${engine(ua)}`);

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

      Exercise 3 · PracticeThe iPhone's slowest first paint

      In the playground, what was the slowest FCP in the WebKit as an iPhone group?

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

        Exercise 4 · PracticeWebKit's first paints

        In the playground, what p75 FCP did the WebKit group get?

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

          Exercise 5 · ChallengeLeave the gaps out

          This summary counts a missing CLS as 0. Fix line 7 so visits without a CLS are left out. What does the fixed program print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          // Beacons from three visits. Only Chrome can measure CLS.
          const beacons = [
            { engine: "Blink", CLS: 0.0468 },
            { engine: "Gecko" },
            { engine: "WebKit" },
          ];
          const values = beacons.map((beacon) => beacon.CLS ?? 0);
          console.log(`CLS values: ${values.join(", ")}`);

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

            Exercise 6 · ChallengeMore ways into CrUX

            Besides the Chrome app itself, the CrUX methodology counts two more ways of using Chrome on Android. Name one.

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

              Check your understanding

              For each question, ask three things: which engine ran the page, what it can measure, and whose visits the number leaves out.

              Field data from every browser quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does the second line of the LCP example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Recorded: LCP from the 40 visits, in ms, labelled by group.
                const lcp = {
                  chrome: [344, 328, 320, 336, 328, 320, 320, 324, 324, 324],
                  firefox: [417, 333, 352, 336, 323, 334, 322, 338, 317, 336],
                  webkit: [1180, 340, 332, 340, 336, 340, 340, 340, 340, 340],
                  iphone: [340, 348, 348, 340, 340, 344, 336, 340, 364, 356],
                };
                const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1];
                
                console.log(`Everyone: p75 LCP ${p75(Object.values(lcp).flat())} ms`);
                console.log(`Chrome only: p75 LCP ${p75(lcp.chrome)} ms`);

                Choose an answer to see the explanation.

              2. Question 2 of 8What does the first line of the user agent example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Recorded: the user agent each group sent.
                const agents = {
                  chrome: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",
                  firefox: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:155.0) Gecko/20100101 Firefox/155.0",
                  webkit: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Safari/605.1.15",
                  iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1",
                };
                
                // Order matters: three of them say "Safari", and Chrome's says "AppleWebKit" too.
                function engine(ua) {
                  if (/iPhone|iPad/.test(ua)) return "WebKit on iOS";
                  if (/Firefox\//.test(ua)) return "Gecko";
                  if (/Chrome\//.test(ua)) return "Blink";
                  if (/Version\/[\d.]+.*Safari\//.test(ua)) return "WebKit on macOS";
                  return "unknown";
                }
                for (const [group, ua] of Object.entries(agents)) console.log(`${group}: ${engine(ua)}`);

                Choose an answer to see the explanation.

              3. Question 3 of 8Why did only Chrome's visits send CLS?

                Choose an answer to see the explanation.

              4. Question 4 of 8Which of these users can be in CrUX?

                Choose an answer to see the explanation.

              5. Question 5 of 8Ravi opens the tea shop in Chrome on his iPhone. Which engine runs the page?

                Choose an answer to see the explanation.

              6. Question 6 of 8A Firefox visit sent no CLS. How should your summary count it?

                Choose an answer to see the explanation.

              7. Question 7 of 8On one laptop, Chrome's p75 INP came out higher than WebKit's for the same click. What should you conclude?

                Choose an answer to see the explanation.

              8. Question 8 of 8Your Chrome-only data says p75 LCP is good. What should you do before trusting it?

                Choose an answer to see the explanation.

              Key takeaways

              • Each metric needs one entry type. Chrome, Firefox and Safari write the ones for TTFB, FCP, LCP and INP. Only Chrome writes layout-shift, so only Chrome reports CLS.
              • web-vitals checks PerformanceObserver.supportedEntryTypes first. Where CLS can't be measured, onCLS stays silent.
              • A missing metric is no answer, not 0. Leave it out of your p75, and say whose visits a number covers.
              • CrUX hears only from opted-in Chrome users on computers and Android. Firefox, Safari and every iPhone browser are missing.
              • On an iPhone, every browser app runs WebKit, apart from apps with Apple's entitlement for another engine.
              • Label each beacon with its engine, and compare each engine with itself: engines can measure a little differently.
              • Read user agents with care. Most say Safari, the system part can be frozen, and test browsers say Headless.

              Remember the one-liner.
              CrUX hears from Chrome; your own beacons hear from everyone, if you label each one and count a missing number as no answer.

              Sources

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

              Coming next: Sending performance data, also in Measuring real users. Get data out of the page reliably without slowing it down or losing the last beacon.

              CompleteFrontend Clear concepts. Working examples.