Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 10 / 18 30 MIN

Front-end performance

Core Web Vitals From Field Data, Not From a Score

A CrUX record is three bins and one percentile per metric. Reading it properly means naming the WordPress object behind the number before you touch any code.

A CrUX record is small enough to read on one screen: three bins and one percentile for each of three metrics. On a WordPress site, a surprising number of front-end decisions have to come out of it.

The failure mode is reading it as a scoreboard. LCP is 3.2 seconds, the band says needs improvement, so compress the images, and six weeks later the images are WebP and LCP is 3.1 seconds. The number was correct; the diagnosis was a guess.

The first module covered what field data is and why it disagrees with a Lighthouse run. This lesson turns a field number into a named object in a codebase, with an owner and a file path.

Each metric is a sentence a visitor would say out loud

Strip the acronyms and the three metrics are complaints. LCP is somebody thinking the page looked empty before the main thing appeared. INP is somebody tapping the variant selector and getting nothing back. CLS is somebody reaching for Add to cart while the button slides away.

Two details in those definitions decide what you can fix. LCP tracks the largest image, text block or video poster in the viewport, and that element can change while the page loads: if a bigger hero swaps in three hundred milliseconds after first paint, the target moves. INP requires an interaction, so bounce traffic contributes nothing to it, and a missing INP is not a passing INP.

The phrasing also splits the work by owner: LCP belongs to whoever owns the hero markup, INP to whoever owns the plugin list, and CLS to whoever owns the above-the-fold layout. The good, needs-improvement and poor bands are the ones from the field-data lesson in module 1.

The 75th percentile describes the worst quarter of your visits

The published number is the value that three quarters of visits came in at or below, which makes it the boundary of the bad quarter, not a description of a typical visit. A shop whose LCP p75 is 3.4 seconds and whose median is 1.8 seconds is not contradictory. It has two populations, and the fast one is not your problem. Shaving bytes off a page that already loads in 1.8 seconds for half your visitors is how a quarter passes and the p75 moves by 0.1 seconds.

The histogram separates those cases. A poor bin at 8 percent is a thin tail, usually a device or network story, and form_factors names the split: an origin that is 93 percent phone with a failing phone p75 has a bytes problem. A poor bin at 30 percent is not a tail, it is the product. Never read a p75 without the bin densities next to it.

A WordPress page loses each metric in one specific place

Core is rarely the cause. These are the three shapes you will find.

MetricThe WordPress object that owns itWhat the record shows
LCP The featured image or a hero in a template part, plus the sizes attribute WordPress generated for it. A large image share in largest_contentful_paint_resource_type, with the load duration or load delay subpart dominating.
INP A plugin's script doing setup work on the main thread. INP failing on phone and passing on desktop, with the worst interactions early in the visit.
CLS A font-display swap, or an ad, promo or embed slot with no reserved height. A small poor bin, and a phone-versus-desktop split that follows the ad inventory.

Take the image case from this module's lab install. One 4000 by 3000 attachment offers the browser candidates from 300 pixels wide at 9,041 bytes up to 2,560 pixels wide at 365,651 bytes. The sizes attribute that wp_get_attachment_image() wrote decides which one a phone downloads, and that attribute describes a content column by default rather than a full-bleed band.

For INP, the same content page measured 43,159 bytes of external JavaScript: 40,106 bytes of Interactivity API module and 3,053 bytes of navigation view script. One plugin with a bundled slider and an analytics library took it to 353,901 bytes. The cost is not transfer alone, because the parse and the setup run on the main thread and a tap that arrives during that work waits.

For CLS there is a WordPress half people do not expect. Font faces declared in theme.json render to @font-face rules whose font-display defaults to fallback, giving a short block period and then a multi-second swap window. Text paints in the fallback face, becomes visible, and moves when the real file arrives, shifting every line if the two faces have different vertical metrics.

A CrUX record is three bins and one number per metric

The values below are the ones published in the CrUX API documentation's own example, kept so you can see the bins land on the band boundaries.

{
  "record": {
    "key": { "origin": "https://shop.example" },
    "metrics": {
      "largest_contentful_paint": {
        "histogram": [
          { "start": 0,    "end": 2500, "density": 0.7925068547983514 },
          { "start": 2500, "end": 4000, "density": 0.1317422195536863 },
          { "start": 4000,              "density": 0.07575092564795324 }
        ],
        "percentiles": { "p75": 2216 }
      }
    },
    "collectionPeriod": { "firstDate": "...", "endDate": "..." }
  }
}

Read four things in order. The key says whether this is an origin or a URL and which form factor was requested. Each bin's start and end define its range and the density is the share of loads inside it. Then percentiles.p75. Then collectionPeriod, a 28-day window, so a deploy from three days ago is not in this number yet.

Two details will bite you in code. Percentiles come from the full data set rather than from the bins, so the histogram and the p75 do not reconcile by arithmetic. And cumulative_layout_shift arrives as a two-decimal double encoded as a string, so $p75 < 0.1 in PHP compares a string to a float. Cast it. Expect percentiles.p75 to be missing when a metric has too few samples.

Beyond the three Core Web Vitals, wire up experimental_time_to_first_byte, form_factors, largest_contentful_paint_resource_type and the four LCP image subparts. Note the experimental_ prefix on TTFB: that set can change.

The LCP resource type and subparts tell you which half of the problem is yours

Before you touch an image, read largest_contentful_paint_resource_type. It reports what share of LCP elements were an image, a text block or a video, and that one field decides which team gets the ticket.

If image dominates, the image pipeline is the right place to work. If text dominates, every hour spent converting JPEGs goes to the wrong element: a text LCP waits on server response, on render-blocking CSS or on a font, and the fix lives in the template and the object cache rather than the media library. This is the most commonly missed line in the record on a content-heavy WordPress site.

When it is an image, four subpart metrics break that LCP into stages. Each is reported at its own p75.

SubpartWhat it measuresWhere the fix lives
..._image_time_to_first_byte Server and network time before the document starts arriving. Cache hit rate, PHP workers, object cache.
..._image_resource_load_delay How long after the page started the image request went out. Lazy loading on the hero, the src set by JavaScript, a CSS background, a low priority hint.
..._image_resource_load_duration The transfer of the image itself. Format, bytes, and which srcset candidate the browser chose.
..._image_element_render_delay The gap between the file arriving and the pixel appearing. A font blocking the paint, main-thread work, or a container JavaScript has not sized yet.

Two documented caveats matter. Each subpart is its own 75th percentile, so the four values do not add up to the overall LCP p75 and are relative weights rather than a budget you can subtract from. And they are only collected on full page loads.

The reading is comparative. A dominant load delay with a small load duration means the file is already small enough and discovery is the problem, so an eager loading attribute or a preload hint is the cheap fix. The reverse, a small load delay and a dominant load duration, means the browser found the image immediately and then spent the time downloading it. Hinting does not help with that one.

Field slow and lab fine means the two measurements saw different pages

Neither tool is broken when they disagree. They loaded different artifacts. A lab run against a warm full-page cache measures the cached product, while the failing quarter of your field visits includes the cache misses.

Origin-level data is also an aggregate: a shop's p75 blends archives, product pages, the cart and the blog, while a Lighthouse run covers one URL, and the LCP element differs between those templates. An archive's largest paint is a grid thumbnail, a product page's is the gallery image.

Devices differ, and on an image-led page that is the largest single factor. CrUX origins are mostly phone traffic, and a desktop run at device pixel ratio 1 asks for a quarter of the pixels a ratio 2 phone asks for. Third parties are missing from most lab runs too, because chat widgets and ad scripts either fail to load in a controlled browser or were never in the page you tested. The gap also runs the other way: a lab run starts with cold DNS and an empty cache, so it can look worse than a returning visit with the font already cached. The sign of the gap tells you nothing. The conditions do.

So do not pick a favourite. Confirm the failure is at p75 for the metric and form factor you care about, and for INP confirm the denominator is interactions. Split by form_factor: phone-fails-desktop-passes points at bytes and CPU. Then reproduce in the lab under the failing quarter's conditions, which means a throttled Slow 4G connection, a 4x CPU slowdown, a fresh profile and a cache-busted URL.

The field record will never name the plugin

CrUX aggregates anonymous browser visits. It knows nothing about your plugin list, your theme, or which script handled the tap that took four hundred milliseconds. When INP is failing and your own RUM carries no attribution, install attribution before you optimise, because the alternative is reading a plugin list and guessing.

Lab 35 minutes

Pull a field record for an origin you own, from the CrUX API with a key or from Search Console's Core Web Vitals report. Write a verdict that is exactly four lines long.

Line one: which metric fails, at which form factor, and at what p75. If everything passes, write the three p75 values and say so.

Line two: from the histogram, what share of loads sit in the poor bin, and is that a tail or the bulk of the traffic?

Line three: for LCP, the resource type fractions and which of the four subparts carries the most weight. If the resource type is text, say what that rules out.

Line four: the WordPress object you would change first, named specifically enough that another developer could find it.

Then answer this: did your lab tool agree with line one? If it disagreed, which condition from the lesson explains the gap?

Show the solution and the reasoning

Four lines is the point. Each is answerable from a specific field, and if you cannot fill one you have found either a sample-size problem or a missing instrumentation task, which is a real result rather than a failed exercise.

On line one the form factor split is what makes the answer actionable. An origin failing LCP on phone and passing on desktop has a delivery and bytes problem. An origin failing on both has a server or render-blocking problem that the phone split only makes worse.

On line two the honest answer is often "a tail". A poor bin under about 15 percent is a site with a slow minority, and chasing that is usually lower value than fixing a metric that fails outright.

On line three the trap is treating the subparts as a budget. They are four separate 75th percentiles taken from four different orderings of the same visits, so a load delay p75 of 900 milliseconds and a load duration p75 of 300 milliseconds do not sum to a share of the total. Compare them against each other.

If largest_contentful_paint_resource_type is missing, the origin sits below the traffic threshold rather than passing, so check that the p75 fields are present before concluding anything from an absence. A page with almost no interactions has too few samples for INP, and the correct line reads "no data" rather than "good".

On line four the point is that the object is nameable. If Lighthouse passed the page and the field failed it, set the throttle and the pixel ratio to match the phone that dominates your traffic and run again. If it still will not reproduce with the page cache bypassed, the difference lives in the population who visits rather than in the page you built.

Progress

Mark this lesson done and it stays marked in this browser.