Monitoring & Regression for Media Delivery
A media pipeline that ships AVIF, tuned srcset breakpoints, and a warm CDN cache is only as good as the last commit that touched it. A designer swaps a hero for an uncompressed 2.4 MB PNG, a marketer drops a third-party carousel above the fold, an intern flips loading="lazy" onto the LCP image — and image weight climbs, LCP drifts past the 2.5 s threshold, and nobody notices until a quarterly report. This guide, part of CDN & Edge Media Delivery, covers the observability layer that catches those regressions: how to measure media performance in the lab and in the field, and how to wire both into automated gates that fail a pull request before the regression reaches users.
The discipline splits cleanly into two data worlds — synthetic (lab) measurement that runs deterministically in CI, and field (Real User Monitoring) data that reflects actual devices and networks. Neither is sufficient alone. A lab test catches a regression the moment it lands but cannot tell you whether real users on a mid-range Android over 4G actually feel it. Field data is authoritative about user experience but arrives days late and cannot block a deploy. Effective media monitoring runs both in a loop.
Lab data versus field data
Lab (synthetic) data is generated by loading a page in a controlled environment — a fixed CPU throttle, a simulated network, a pinned browser build. Lighthouse CI and WebPageTest both produce lab data. Its defining property is repeatability: the same commit produces nearly the same numbers, which is exactly what a CI gate needs to draw a pass/fail line.
Field data is aggregated from real page loads — every device, every network, every browser your actual audience uses. The Chrome UX Report (CrUX) and your own RUM beacon are field sources. Field data captures the edge cases lab tests never simulate: a 5-year-old phone on congested cellular, a browser extension injecting scripts, a cold CDN edge in a region you forgot about. Its defining property is authority — it is the ground truth for Core Web Vitals — but it is slow, aggregated, and unactionable at the level of a single commit.
The two disagree constantly, and the disagreement is informative. If lab LCP is 1.8 s but field p75 LCP is 4.1 s, your lab profile is too optimistic — real users are on slower hardware than your throttle simulates, or your CDN is missing edges near them. If lab regresses but field stays flat, you may have caught a regression that only affects a device class outside your median. Reconciling the gap is the core skill of media observability.
| Property | Lab / synthetic | Field / RUM |
|---|---|---|
| Source | Lighthouse CI, WebPageTest | CrUX, self-hosted RUM beacon |
| Reproducible | Yes (fixed throttle + pinned browser) | No (real device/network variance) |
| Latency to signal | Seconds (per commit) | Days (28-day rolling for CrUX) |
| Can gate a deploy | Yes | No (too slow, aggregated) |
| Captures real audience | No (single simulated profile) | Yes (full device/network distribution) |
| Metric shape | A single value per run (median of N) | A distribution; you read the p75 |
| Per-element attribution | Yes (audit names the asset) | Only with your own beacon; CrUX has none |
| Segmentable by | Preset, form factor, throttle profile | Device class, country, effective connection type |
| Best at | Catching regressions early | Confirming user-felt impact |
Two mechanics explain most of the confusion between them. On the lab side, Lighthouse’s default mobile preset does not actually throttle the network — it applies simulated throttling, running the page on an unthrottled connection and then replaying the request graph through the Lantern model at 1.6 Mbps down, 750 Kbps up and 150 ms RTT with a 4× CPU slowdown. That is why a lab LCP is reproducible to within a few percent but is a model of a slow connection rather than a measurement of one; switching to throttlingMethod: "devtools" (applied throttling) produces slower, noisier, more realistic numbers. Whichever you pick, never mix the two across a baseline and a candidate build — the delta will be meaningless.
On the field side, CrUX reports the 75th percentile of each metric over a 28-day rolling window, refreshed daily through the API and monthly in the BigQuery dataset. A URL only appears if it has enough eligible samples, so low-traffic URLs fall back to origin-level data; the samples themselves come only from opted-in Chrome users on qualifying devices, which means Safari and Firefox traffic is invisible to CrUX entirely. Practically: CrUX answers “is this origin good for most Chrome users this month”, never “did last Tuesday’s deploy regress /products”. For that second question you need your own beacon, and the gap between the two signals is measured in days.
The three foundations of media observability
A complete setup rests on three complementary measurement types. Each answers a different question, and each has a dedicated guide in this section.
Synthetic CI budgets — the fast gate. On every commit, a headless run measures LCP, total image bytes, and image request count against a fixed budget, and fails the build on breach. This is where Lighthouse CI budget enforcement for image weight lives. It is deterministic, cheap, and blocks before merge.
Filmstrip and visual diffing — the render-timeline gate. A budget can pass while the page still looks slower, because bytes and milliseconds do not fully describe perceived load. WebPageTest filmstrip diff automation captures frame-by-frame screenshots and Speed Index so you can diff the visual progress of a baseline build against a candidate and see exactly which frame the hero appears in.
RUM and field data — the truth gate. Tracking LCP field data with the CrUX API pulls p75 LCP for your origin and specific URLs, tracks it over time, and alerts when the field number crosses a Core Web Vitals threshold — the signal that a regression actually reached users.
The loop is the whole point: the CI gate blocks obvious regressions before merge, the deploy ships what survives, field monitoring confirms the real-world effect, and any field breach circles back as a new commit. Lab catches the cause fast; field confirms the effect slowly. Skip either half and you either ship blind or find out too late.
Instrumenting your own RUM beacon
CrUX is the neutral, comparable-across-sites field source, but it is coarse: p75 only, a 28-day window, no per-element attribution, and a traffic floor that hides low-traffic pages. A self-hosted RUM beacon fills those gaps. It reports today’s loads immediately, lets you compute any percentile you like, segments by device and route, and — critically for media work — can capture the actual LCP element so you know which image regressed. The CrUX field-data guide covers the aggregate backstop; the beacon below is the immediate, drill-down half of the field picture.
Google’s web-vitals library reads the same metrics Chrome feeds into CrUX, so a self-hosted beacon and CrUX stay directionally consistent. The key detail for media monitoring is the attribution build, which exposes the LCP element and its resource URL:
// rum-beacon.mjs — capture field LCP with the offending image URL attached.
// Import from web-vitals/attribution to get the element and resource details.
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
const a = metric.attribution;
navigator.sendBeacon('/rum', JSON.stringify({
name: 'LCP',
value: metric.value, // LCP in ms for THIS load
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
// element is the LCP node; url is the image resource that painted it —
// this is what lets you attribute a field regression to a specific asset.
element: a.element, // e.g. 'img.hero'
url: a.url, // e.g. '/img/hero.avif'
// Sub-part timing: which phase dominated the LCP budget.
ttfb: a.timeToFirstByte, // server + network before the byte stream
loadDelay: a.resourceLoadDelay, // discovery gap — high = preload-scanner missed it
loadTime: a.resourceLoadDuration, // download time — high = the image is too heavy
renderDelay: a.elementRenderDelay, // decode + paint after bytes arrive
route: location.pathname,
// navigator.connection.effectiveType lets you segment by network class.
conn: navigator.connection?.effectiveType,
}));
});
The four attribution sub-parts localize the fault before you open a single dashboard. A high resourceLoadDelay means the preload scanner never found the LCP image — reach for fetchpriority and preload hints. A high resourceLoadDuration means the image is simply too heavy — a format or compression problem the AVIF vs WebP benchmarks address. A high elementRenderDelay points at decode cost or a render-blocking resource ahead of the image. sendBeacon is used deliberately: it survives the page unload that a fetch would drop, so beacons are not lost when users navigate away mid-load.
A worked example makes the triage concrete. Suppose the beacon reports a p75 LCP of 3,180 ms on /products, and the median split across the four sub-parts is TTFB 420 ms, resourceLoadDelay 1,240 ms, resourceLoadDuration 1,180 ms, and elementRenderDelay 340 ms. The four numbers sum to the whole metric by construction — that is the point of the attribution build — so the largest one is, arithmetically, where the fix has to land.
Read that way, compressing the hero is not enough on its own: even an instant download leaves 2,000 ms of TTFB, discovery gap and render delay, still over the 2.5 s line. The resourceLoadDelay segment is the tell that the image is not in the initial HTML — it is being injected by a client-side component, set as a CSS background, or hidden behind a lazy-loading attribute the preload scanner cannot resolve. Fixing discovery and weight together is what moves a p75 across a threshold; fixing one usually does not.
Tradeoff: a RUM beacon fires on every page load, so naive collection can generate enormous volume and cost. Sample — 5–10% of sessions is usually enough to stabilize a p75 — and aggregate server-side into per-route, per-device buckets rather than storing raw events forever.
Image weight and LCP budgets
Two metrics dominate media regressions, and both belong in a budget.
Image weight — the total transferred bytes of all image resources on a page — is the leading indicator. It climbs before LCP visibly degrades and is trivially attributable to a specific asset. A sensible starting budget for a content page is 500 KB total image bytes on mobile and no more than 15 image requests above the fold. The exact number matters less than the ceiling: once a budget exists, every commit that pushes past it must justify itself. Format choice is the biggest lever here — migrating a hero from JPEG to AVIF typically cuts its bytes 40–50%, which is why the AVIF vs WebP compression benchmarks feed directly into a realistic budget.
LCP (Largest Contentful Paint) is the outcome metric. For media-heavy pages the LCP element is almost always an image, so LCP is effectively a measure of how fast your single most important image renders. The Core Web Vitals “good” threshold is p75 LCP ≤ 2.5 s. In the lab you assert on a single LCP number under a fixed throttle; in the field you assert on the p75 across your whole audience. The dominant lab-side levers are the LCP image’s byte size, its fetchpriority, and whether it is discoverable by the preload scanner — covered in using fetchpriority to optimize critical media.
Budgets are derived, not invented. Measure the templates you care about, then set each ceiling roughly 12% above the measured value so ordinary variance — a different srcset candidate winning on a slightly different viewport, a rounding difference in an encoder — cannot trip the gate on its own. A realistic starting sheet for a content site looks like this:
| Template | Measured image bytes | Image requests | Byte ceiling | LCP baseline | LCP ceiling |
|---|---|---|---|---|---|
| Home (hero + 6 cards) | 438 KB | 11 | 500 KB | 1,840 ms | 2,100 ms |
| Article | 262 KB | 8 | 300 KB | 1,510 ms | 1,750 ms |
| Product detail (gallery) | 704 KB | 19 | 800 KB | 2,180 ms | 2,450 ms |
| Category listing | 596 KB | 24 | 680 KB | 1,960 ms | 2,250 ms |
| LCP image alone (any template) | 96 KB | 1 | 120 KB | — | — |
The last row is the important one. A per-asset ceiling on the LCP image — separate from, and much smaller than, the page total — is what stops the hero from absorbing the whole budget, and it is the one number that maps directly onto the metric users feel. Note that the product gallery gets a larger allowance and a later LCP ceiling than the article template: budgets are per-template, because a page whose entire purpose is a 19-image gallery is not regressing simply by being heavier than a text article.
Tradeoff: an image-weight budget and an LCP budget can conflict. Aggressively compressing every image lowers total weight but can starve the LCP image of quality, or push you toward a format whose slower decode raises LCP on low-end devices. Budget the LCP image separately from the aggregate: give the hero a generous per-asset byte allowance and hold the collective mass of below-fold images to a tight ceiling.
What LCP actually measures on a media page
Budgets are only meaningful if you know which element the metric is watching, and the selection rules surprise people. The browser maintains a running set of LCP candidates drawn from a deliberately small element list: <img>, an <image> element inside an inline SVG, a <video> element’s poster (and, in current Chrome, its first painted frame), an element with a CSS background-image loaded via url(), and block-level elements containing text. Every time a larger candidate paints, the browser emits a new largest-contentful-paint entry. The metric is not finalized on load — it stops updating at the first user interaction (a click, tap, or key press), which is why a page that scrolls itself or auto-focuses an input can report an artificially early LCP in the lab and a very different one in the field.
Three details matter specifically for media monitoring:
Size is the visible size, not the intrinsic size. A candidate’s size is its rendered area, and for an image that overflows or is scaled down, the browser uses the smaller of the intrinsic and the displayed area. Shipping a 3,000 px-wide source into a 600 px slot therefore buys no LCP credit at all — it only spends bytes. This is the mechanism that makes an image-weight budget and an LCP assertion complementary rather than redundant.
Low-entropy images are excluded. Chrome ignores candidates whose encoded size relative to their painted area falls below roughly 0.05 bits per pixel — the heuristic that stops a solid-colour placeholder or a giant blurred LQIP from being reported as the LCP element. In practice this means a blur-up placeholder is skipped, the real image becomes the candidate when it decodes, and your beacon correctly attributes the metric to the full-resolution asset. If you see LCP attributed to a placeholder, your placeholder is too detailed.
Removed elements still count. Since Chrome 112, a candidate that painted and was then removed from the DOM keeps its LCP entry. A carousel that paints slide one, then swaps in slide two, is measured on slide one — so an autoplaying hero carousel can report a fast LCP that no user experiences as fast. Cross-check any carousel template against the filmstrip rather than trusting the number.
The practical consequence for a monitoring setup: always record which element produced the metric alongside the value. A p75 that moves because the LCP element changed — from a hero image to a headline, say, after a layout tweak — is not the same event as the same element getting slower, and treating them alike produces weeks of misdirected work. Both the CI report and the beacon payload should carry the element selector, which is exactly what metric.attribution.element provides.
Comparing the tooling
The three tools you will wire together occupy different points on the lab-field axis and gate differently.
| Capability | Lighthouse CI | WebPageTest | CrUX API |
|---|---|---|---|
| Data type | Lab (synthetic) | Lab (synthetic) | Field (real users) |
| Primary media metrics | LCP, total byte weight, resource-summary image count | LCP, Speed Index, filmstrip frames | p75 LCP, CLS, INP (origin + URL) |
| Runs per commit | Yes (lhci autorun) |
Yes (via API) | No (aggregated, 28-day window) |
| Can fail a build | Yes (assertions / budgets) | Yes (custom script on metric deltas) | Not directly (alert only) |
| Visual/render evidence | Screenshot only | Full filmstrip + video | None |
| Cost | Free (self-hosted server or temporary storage) | Free tier limited; paid API for volume | Free (Google API key, quota-limited) |
| Best gate role | Fast per-PR budget | Render-timeline / visual diff | Post-deploy field alert |
Warning: do not try to make CrUX a merge gate. Its data is a 28-day rolling aggregate and lags a deploy by days; a passing CrUX number reflects the previous month of traffic, not the commit under review. Use it strictly as a post-deploy alarm, and keep the merge decision on the lab tools.
Standing up a media-perf regression gate
The following sequence takes a repository from no media monitoring to a full loop. Each step is elaborated in the linked guides.
-
Pick target pages and set baselines. Choose the 3–5 highest-traffic templates (home, a product page, an article). Run Lighthouse and WebPageTest against production to record today’s LCP, total image bytes, and Speed Index. These become the budget numbers — set the ceiling roughly 10–15% above the current value so normal noise does not trip the gate.
-
Add a Lighthouse CI budget assertion. Commit a
budget.jsoncapping image bytes and image request count, and alighthouserc.jsonthat runslhci autorunwithassertionsonlargest-contentful-paint. Wire it into your CI so it runs on every pull request. Full setup: Lighthouse CI budget enforcement for image weight. -
Pin the browser. A budget is only reproducible if the Chrome version is fixed. Install a specific Chrome build in CI and pass its path to Lighthouse, so a Chrome auto-update cannot silently shift your numbers and produce phantom regressions.
-
Add filmstrip diffing for the LCP templates. For pages where render timing matters more than raw bytes, script a WebPageTest run that pulls the filmstrip and Speed Index, and diff against a stored baseline. See WebPageTest filmstrip diff automation for LCP.
-
Run N and take the median. Synthetic tests are noisy. Run each WebPageTest URL 3–5 times and compare medians, never single runs, or network jitter will flap the gate.
-
Stand up field monitoring. After deploy, poll the CrUX API on a schedule for p75 LCP on your key URLs, store the series, and alert on a threshold cross. See tracking LCP field data with the CrUX API.
-
Close the loop. Route field alerts to the same channel your team triages, so a p75 regression becomes a tracked issue that produces a fix commit — feeding back into step 2.
Warning: step 1 is the step teams skip, and skipping it is fatal. A budget invented from a blog post rather than measured from your own production pages either fails on the first run — training everyone to merge past a red check — or sits so far above reality that nothing ever trips it. Measure first, then add headroom.
Validation commands
Two commands verify that the two halves of the loop are actually wired up. The first proves the lab side is producing the audits a budget depends on, straight from a Lighthouse report on disk:
# Run Lighthouse once and dump JSON, then read the image row of the
# resource-summary audit — the exact number budget.json compares against.
lighthouse https://example.com/products/ --output=json --output-path=./lhr.json --quiet
# .items[] holds one row per resource type; "image" is the one a media
# budget caps. transferSize is BYTES here, while budget.json is in KB.
jq '.audits["resource-summary"].details.items[] | select(.resourceType=="image")' ./lhr.json
# The LCP element itself, so you can confirm the metric is watching the
# asset you think it is (see the LCP candidate rules above).
jq -r '.audits["largest-contentful-paint-element"].details.items[0].items[0].node.snippet' ./lhr.json
The second proves the field side is reachable and returns the percentile you intend to alert on:
# CrUX API: p75 LCP for one URL. Omit "url" and pass "origin" instead for
# origin-level data when a specific URL has too few samples to qualify.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/products/","formFactor":"PHONE","metrics":["largest_contentful_paint"]}' \
| jq '.record.metrics.largest_contentful_paint.percentiles.p75,
.record.key.formFactor'
A 404 from that endpoint means the URL has insufficient CrUX samples, not that your key is wrong — retry at origin level before debugging credentials. That single distinction accounts for most of the time teams lose standing field monitoring up.
Tradeoffs, failure modes, and debugging
Synthetic and field monitoring each fail in characteristic ways. Knowing the failure mode tells you which knob to turn.
| Failure mode | Cause | Fix |
|---|---|---|
| Gate flaps red/green on identical code | Single-run variance; unthrottled CI runner under load | Take the median of 3–5 runs; pin CPU/network throttle; use a dedicated runner |
| Phantom regression after a quiet week | Chrome auto-updated in CI, shifting lab timings | Pin the exact Chrome build and pass its path explicitly |
| Lab is green but users complain | Lab profile too optimistic vs real device mix | Calibrate throttle to your CrUX device distribution; add a slower profile |
| Field p75 spikes with no matching commit | Traffic-mix shift (a slow region or device class grew), not a code change | Segment CrUX/RUM by device and country before blaming a deploy |
| Field data won’t move after a fix | 28-day rolling window dilutes the change | Wait a full window; watch the daily RUM beacon for the leading edge |
| Budget passes but page looks slower | Bytes/ms unchanged but render order regressed | Add filmstrip/Speed Index diffing, which byte budgets miss |
| Image budget breached by one asset | An un-optimized upload bypassed the pipeline | Attribute via resource summary; enforce format conversion at build |
| LCP jumps with no image change | The LCP element itself changed (hero → headline, or a carousel swap) | Record the element selector with every measurement and compare elements before values |
| Beacon p75 diverges from CrUX p75 | Beacon samples only browsers that ran the script, and only sampled sessions | Compare trends, not absolute values; keep the sampling rate fixed so the series stays comparable |
| Budget passes locally, fails in CI | Different Chrome build, different throttling method, or a cold CDN in the runner’s region | Pin Chrome, pin throttlingMethod, and always compare like-for-like environments |
When a gate fires, debug from cause to effect. Start with the Lighthouse resource-summary and total-byte-weight audits to find which image grew. Confirm the render impact in the WebPageTest filmstrip — did the hero’s paint frame move later? Finally, once deployed, watch whether the field p75 actually shifts; if lab regressed but field held flat, the regression lives outside your median audience and may be lower priority than a field-confirmed one. That triage order — attribute in the lab, confirm in the field — keeps you from chasing noise. Written as a decision, it collapses into four questions and four answers:
Frequently asked questions
Why does my lab LCP disagree with my CrUX p75 LCP?
Lab LCP is one simulated device on one throttle profile; CrUX p75 is the 75th percentile across your whole Chrome audience over 28 days. A persistent gap is not an error — it means your lab profile is more optimistic than the slowest quarter of real visits. Recalibrate the throttle against your CrUX device and effective-connection-type distribution instead of loosening the assertion.
How much headroom should an image-weight budget have?
Roughly 10–15% over the measured baseline. Tighter, and normal variance in responsive-image selection flaps the gate; looser, and a genuinely heavy asset lands without breaching. Re-baseline deliberately whenever you intentionally ship a heavier template, in its own commit, so the change is attributable.
Can a self-hosted beacon replace the CrUX API?
No, and the reverse is also false. The beacon is faster, per-route, per-element, and covers every browser that runs your script; CrUX is the neutral Chrome-collected dataset that external tools and competitive comparisons use. Keep both — the beacon to act, CrUX to confirm.
Why didn’t field LCP improve after we shipped the fix?
The 28-day rolling window averages your fix with 27 days of pre-fix traffic. Watch the daily beacon p75 for the leading edge, and expect the CrUX number to settle only after a full window has turned over. If the beacon is also flat, the fix did not address the dominant attribution sub-part.
Should image budgets be per-page or site-wide?
Per-template. A gallery page and an article have legitimately different media profiles, and a single site-wide ceiling either strangles the gallery or lets the article rot. Give each audited template its own budget.json entry, plus one shared per-asset ceiling for the LCP image.
Related
- Lighthouse CI Budget Enforcement for Image Weight —
lhci autorun,budget.json, and GitHub Actions assertions that fail a PR on image-byte breach - WebPageTest Filmstrip Diff Automation for LCP — scripting the WPT API to diff visual progress and Speed Index between builds
- Tracking LCP Field Data with the CrUX API — querying p75 field LCP by origin and URL and alerting on regressions
- Using fetchpriority to optimize critical media — the main lab-side lever for the LCP image
- AVIF vs WebP Compression Benchmarks — format data that sets a realistic image-weight budget
- CDN & Edge Media Delivery — the parent section on edge negotiation, caching, and delivery