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.
- 01Know who can measure whatSay which of TTFB, FCP, LCP, INP and CLS each browser can report, and check before you measure.
- 02Know whose visits CrUX missesExplain why Firefox, Safari and every iPhone browser are missing from CrUX, and from any Chrome-only data.
- 03Compare 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:
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:
// 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.0468andiphone 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:
All 40 visits sent LCP. Each bar is one visit, in the order the course made them.
Pick CLS, then Firefox. Every row says "no answer". Hold on to that phrase; the next section explains it.
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
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
navigationentry. - FCP, First Contentful Paint, is when the first text or image appeared. It comes from
paintentries. - LCP comes from
largest-contentful-paintentries. - INP comes from
evententries that carry aninteractionId, the number that ties one click's events together. - CLS comes from
layout-shiftentries.
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:
// 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, INPandwebkit 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:
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.
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:
// 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,
typesholds the entry types a metric asked for. For CLS, that'slayout-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.
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:
// 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:
| What a visit sent | What it tells you |
|---|---|
CLS 0.0468 from Chrome | Chrome watched for layout shifts. It saw the promo push the page down. |
CLS 0 from Chrome | Chrome watched for layout shifts and saw none. Nothing moved. |
| No CLS from Firefox or Safari | The 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:
// 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:
// 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:
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:
// 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 msandiphone: 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:
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.effectiveTypeis Chrome's guess at the kind of network, such as4g. It said4g.navigator.deviceMemoryis about how much memory the device has, in gigabytes. It said16.
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.
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
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:
// 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:
// 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 forChrome/, before any check for Safari. - Line 14 only calls a visit WebKit on macOS if it says
Version/andSafari/, and nothing earlier matched. - Line 17 prints
chrome: Blink,firefox: Gecko,webkit: WebKit on macOSandiphone: 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.
- LCP
- INP
- FCP
- TTFB
- CLS
- navigator.connection.effectiveType
- navigator.deviceMemory
- Being counted in CrUX
Sort each one: every engine, or Chrome only.
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.
This shop had 10 Chrome visits and 10 iPhone visits. What does the first line print?
// 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)}`);It prints Counting "no answer" as 0: p75 CLS 0.0468. With half the visits from Chrome, the zeros only fill the bottom half, so the p75 lands on a Chrome value. The mistake hides until more of your visitors use other browsers.
Someone moved a check for "Safari/" to the top of the function. What does the first line print now?
// 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)}`);It prints chrome: Safari. Chrome's user agent says Safari too, so a Safari check that comes first catches it. Only Firefox's string, which has no Safari/, gets past it.
In the playground, what was the slowest FCP in the WebKit as an iPhone group?
155 ms, on its first visit. The other nine painted in 12 to 21 ms. Like every group, its first visit was its slowest.
In the playground, what p75 FCP did the WebKit group get?
16 ms. On the same laptop, Chrome's p75 FCP is 44 ms. Like the INP readings, these come from different thermometers, so compare WebKit with WebKit.
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?
// 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(", ")}`);// Beacons from three visits. Only Chrome can measure CLS.
const beacons = [
{ engine: "Blink", CLS: 0.0468 },
{ engine: "Gecko" },
{ engine: "WebKit" },
];
const values = beacons.filter((beacon) => beacon.CLS !== undefined).map((beacon) => beacon.CLS);
console.log(`CLS values: ${values.join(", ")}`);It prints CLS values: 0.0468. The Gecko and WebKit visits couldn't measure CLS, so they're no answer. Leaving them out keeps 0 for visits that really held still.
Besides the Chrome app itself, the CrUX methodology counts two more ways of using Chrome on Android. Name one.
Mobile apps using Custom Tabs, and WebAPKs. The Chrome User Experience Report lesson explains what each one is. The methodology lists both under Android versions of Chrome.
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.
Question 1 of 8What does the second line of the LCP example print?
Read the code, then predict// 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.
Question 2 of 8What does the first line of the user agent example print?
Read the code, then predict// 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.
Question 3 of 8Why did only Chrome's visits send CLS?
Choose an answer to see the explanation.
Question 4 of 8Which of these users can be in CrUX?
Choose an answer to see the explanation.
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.
Question 6 of 8A Firefox visit sent no CLS. How should your summary count it?
Choose an answer to see the explanation.
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.
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.supportedEntryTypesfirst. Where CLS can't be measured,onCLSstays 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:
- Rendering engine, MDN.
- WebKit Features for Safari 26.2, WebKit.
- web-vitals 6.2.3 README, Google Chrome.
- web-vitals 6.2.3: src/lib/observe.ts, Google Chrome.
- web-vitals 6.2.3: src/onCLS.ts, Google Chrome.
- CrUX methodology, Chrome for Developers.
- Paint Timing, W3C.
- Event Timing, W3C.
- App Review Guidelines, Apple.
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.