Measuring Core Web Vitals per CMS Content Type
This guide, part of Core Web Vitals Optimization, makes field data useful for a headless site by attaching CMS context to every measurement: the content type, the template, the blocks on the page, the locale and the entry id. With that context, a regression stops being “mobile LCP got worse” and becomes “product pages with a video hero got worse in Japanese since Tuesday’s release”.
Search console and the Chrome UX Report group pages by URL patterns and origin, which is too coarse for headless sites where one route can render very different layouts depending on content. Two product pages at the same route pattern can differ completely: one has a simple image hero, the other a video, a carousel and three embeds. Averaging them hides the problem. Your own real-user monitoring can do better, because the page knows exactly what it rendered.
The Problem
A retailer’s mobile LCP rose from 2.3 to 2.9 seconds over a month, and the team could not find a cause. No deploy stood out; lab tests of the main templates looked fine. Weeks later, someone noticed that merchandisers had started using a new “video hero” block on many category pages. The block was fine technically, but its poster image was a large uncompressed PNG uploaded by editors. Nothing in the monitoring could have pointed to it, because metrics were grouped by route, and category pages with and without the block shared a route.
How Context-Rich Measurement Works
Render context into the page. The server knows the entry id, content type, template, locale and the list of block types on the page, in order. Render them as data attributes on the <body> or <main> element, and give each block element a data-block attribute with its type.
Report metrics with attribution. The web-vitals library’s attribution build reports, for LCP, the element that was the LCP candidate, and for INP, the element that was interacted with and the long task responsible. Resolve those elements to their closest data-block ancestor to learn which block type caused the value.
Aggregate by dimension. Store each metric with its dimensions and compute the 75th percentile per content type, template, first block type, LCP block type and locale. Compare across releases and across content changes.
Sample sensibly. Report from a sample of sessions, such as 20 percent, which is plenty for large sites and keeps costs low. Small locales may need a higher sample rate to produce stable numbers.
Implementation
The layout renders CMS context as data attributes. The block renderer adds the block type to each block wrapper.
// app/[locale]/[...slug]/page.tsx (excerpt)
return (
<main
data-entry={page.id}
data-type={page.contentType}
data-template={page.template}
data-locale={locale}
data-blocks={page.blocks.map((b) => b._type).join(",")}
>
{page.blocks.map((b) => (
<div key={b._key} data-block={b._type}>
<BlockView block={b} />
</div>
))}
</main>
);
A small client script reports metrics with the context and the block responsible for LCP and INP.
// app/rum.client.ts
import { onLCP, onINP, onCLS, type MetricWithAttribution } from "web-vitals/attribution";
const SAMPLE = Number(process.env.NEXT_PUBLIC_RUM_SAMPLE_RATE ?? "0.2");
const sampled = Math.random() < SAMPLE;
function blockOf(selector?: string): string | null {
if (!selector) return null;
const el = document.querySelector(selector);
return el?.closest<HTMLElement>("[data-block]")?.dataset.block ?? null;
}
function send(metric: MetricWithAttribution) {
if (!sampled) return;
const main = document.querySelector<HTMLElement>("main[data-type]");
const attribution = metric.attribution as Record<string, unknown>;
const target = (attribution.target ?? attribution.interactionTarget ?? attribution.largestShiftTarget) as string | undefined;
navigator.sendBeacon("/api/rum", JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
type: main?.dataset.type,
template: main?.dataset.template,
locale: main?.dataset.locale,
entry: main?.dataset.entry,
firstBlock: main?.dataset.blocks?.split(",")[0],
block: blockOf(target),
device: matchMedia("(max-width: 767px)").matches ? "mobile" : "desktop",
nav: (performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined)?.type,
}));
}
onLCP(send);
onINP(send);
onCLS(send);
The aggregation query computes p75 per dimension over a recent window.
SELECT type, first_block, locale, device,
percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75,
count(*) AS samples
FROM rum_metrics
WHERE name = 'LCP' AND ts >= now() - interval '7 days'
GROUP BY type, first_block, locale, device
HAVING count(*) >= 200
ORDER BY p75 DESC;
The HAVING clause hides groups with too few samples to be trusted, which prevents chasing noise in small locales or rare block combinations.
Linking metrics to content changes
Field regressions often follow content changes rather than deploys. Record publish events from CMS webhooks in the same store, with entry id and content type, and overlay them on the metrics chart. A jump in p75 LCP for one content type that coincides with a batch of publishes points at content; one that coincides with a deploy points at code. For individual entries with enough traffic, the entry id dimension shows exactly which pages are slow, which gives editors something concrete to fix.
Alerting on regressions
A dashboard is only useful if someone looks at it. Add alerts on the aggregated data: when the p75 of any metric for a content type, first block type or locale crosses its threshold for two consecutive days with enough samples, notify the owning team with the dimension values and a link to the chart. Include the most recent deploys and CMS publishes in the alert, so the recipient sees the likely cause immediately. Keep alert rules few and specific; one alert per template and metric is usually enough, and noisy rules should be tightened or removed rather than muted.
Configuration Reference
| Dimension | Source | Use |
|---|---|---|
| Content type, template | data attributes | Compare page kinds. |
| First block type | data attributes | Explains most LCP differences. |
| LCP, INP, CLS block | web-vitals attribution | Finds the responsible component. |
| Locale | data attribute | Fonts, text length, market networks. |
| Entry id | data attribute | Individual slow pages with enough traffic. |
| Device and navigation type | client | Separate mobile, desktop, back-forward loads. |
Gotchas & Edge Cases
- Back-forward cache restores. Pages restored from the back-forward cache report very fast metrics. Record the navigation type and analyse restores separately.
- Soft navigations. In single-page navigation, metrics after the first page load are attributed to the first URL unless the library supports soft navigations. Report context from the page actually shown.
- Too many dimensions. Every extra dimension divides samples. Aggregate by one or two dimensions at a time and require minimum sample counts.
- Privacy. Metrics need no user identifiers. Keep entry ids but no session or user ids, and respect consent requirements for analytics.
Worked Example
After the video hero incident, the retailer added CMS context to its RUM data. The first report by first block type immediately showed video hero pages at 4.2 seconds p75 LCP on mobile, against 2.1 for image heroes. The block’s poster image was moved through the image pipeline with size limits, and the block’s validation in the CMS was tightened. Two weeks later a new carousel block regressed INP in the German locale only; the INP attribution pointed at the carousel’s click handler, which formatted prices with a slow locale-specific routine, and the fix shipped the same day.
Sharing the Numbers with Content Teams
Once metrics carry CMS context, they are meaningful to people outside engineering. Share a simple view with content and merchandising teams: p75 LCP by block type and a list of their slowest pages with enough traffic. Explain the thresholds once, and show the effect of choices they control, such as a video hero versus an image hero, or three embeds versus one. Teams that see the cost of a block tend to use it more deliberately, and they often find fixes engineering would not, such as replacing an oversized image or moving an embed further down the page. Pair the view with the performance budgets in the content model, so the easy path for editors is also the fast one.
Rollout Checklist
- Render entry, type, template, locale and block types as data attributes.
- Report web-vitals with attribution, resolving targets to block types.
- Sample sessions and require minimum sample counts per group.
- Aggregate p75 by type, first block, locale and device.
- Overlay CMS publish events and deploys on metric charts.
- Share block-level results with content teams.
Frequently Asked Questions
Do we still need the Chrome UX Report?
Yes. It is what search uses and a useful independent check on your own sampling and instrumentation. Your own RUM explains it in CMS terms.
How many samples are enough?
A few hundred samples per group gives a reasonably stable 75th percentile. Busy templates reach that in hours; small locales may need a full week of data.
Can attribution identify third-party scripts?
For INP, the long task attribution often shows script sources. Group them by origin to see which third parties slow interactions.
Should editors see entry-level metrics in the CMS?
It helps, as a small panel or sidebar extension showing the page’s recent field metrics. Keep it simple: one number per metric and its rating.