Core Web Vitals in Headless Front Ends
Decoupling the front end from the CMS moves the largest contentful paint behind an API call and puts a hydration bundle between the visitor and their first interaction — two structural changes that regress Core Web Vitals in ways a monolithic template never did. This section isolates where each metric breaks in a decoupled stack and gives the framework-level fix and the field measurement that proves it worked.
Prerequisites
Before working through the fixes below, confirm you have:
- A field data source — Chrome UX Report access via the CrUX API or BigQuery, or a real-user monitoring script emitting
web-vitalsmeasurements grouped by route template. - A representative URL sample per template — one home, one listing, one article, one product route at minimum, since vitals differ enormously between templates.
- Framework versions that support the modern primitives: Next.js 14+ (App Router), Nuxt 3.10+, or SvelteKit 2+ — each needed for the image, streaming, and route-rules APIs used here.
- A Lighthouse CI runner wired into pull requests. Setup is covered in integrating Lighthouse CI into headless pipelines.
- Known rendering mode per route. A route served by client-side rendering has a fundamentally different vitals profile from a statically generated one — settle that first in ISR vs SSG vs CSR routing.
If your origin response time is unstable, fix that before tuning anything else — the same slow origin that inflates LCP also suppresses crawl budget in headless deployments.
Where Each Metric Breaks in a Decoupled Stack
The three metrics fail at three different points on the request timeline. LCP fails early, in the fetch-and-render path; CLS fails in the middle, when CMS-driven media arrives after layout; INP fails late, once hydration competes with the visitor’s first tap. Mapping a complaint to the right stage is most of the diagnosis.
Step-by-Step Implementation Workflow
Step 1 — Read the field data before touching code
Field percentiles decide whether you have a problem. Query CrUX per URL group so a fast article template is not averaged together with a slow catalogue.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/blog/my-post","formFactor":"PHONE"}' \
| jq '.record.metrics | {
lcp: .largest_contentful_paint.percentiles.p75,
cls: .cumulative_layout_shift.percentiles.p75,
inp: .interaction_to_next_paint.percentiles.p75
}'
Validation: You get three p75 numbers. Thresholds are 2500 ms for LCP, 0.1 for CLS, and 200 ms for INP. Anything inside those is not the problem you should be working on.
Step 2 — Attribute the LCP to a stage
An LCP number alone is not actionable. Break it into its four sub-parts so you know whether the origin, the discovery of the image, the download, or the render is at fault.
// attribution.js — ship with your RUM bundle
import { onLCP } from 'web-vitals/attribution';
onLCP(({ value, attribution: a }) => {
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'LCP',
value,
element: a.element,
ttfb: a.timeToFirstByte,
loadDelay: a.resourceLoadDelay,
loadTime: a.resourceLoadDuration,
renderDelay: a.elementRenderDelay,
}));
});
Validation: Aggregate the four fields by template. If resourceLoadDelay dominates, the browser discovered the hero image too late — a preload or a static render fixes it. If timeToFirstByte dominates, the fix belongs in edge caching behavior for SEO, not in the front end.
Step 3 — Remove the API round trip from the critical path
The most common headless LCP regression is a hero image whose URL is only known after a CMS query resolves. Render the route ahead of the request so the markup — and the preload hint — is already in the HTML.
// next.config.mjs — statically generate article routes, revalidate on publish
export default {
experimental: { staleTimes: { dynamic: 0 } },
images: { formats: ['image/avif', 'image/webp'] },
};
// app/blog/[slug]/page.jsx
export const revalidate = 3600;
export async function generateStaticParams() {
const posts = await cms.posts.list({ fields: ['slug'] });
return posts.map((p) => ({ slug: p.slug }));
}
Validation: curl -s https://example.com/blog/my-post | grep -o '<img[^>]*hero[^>]*>' returns the hero markup from the server response. If it is absent, the image is still being discovered client-side.
Step 4 — Reserve space for every CMS-driven asset
CLS in a decoupled build almost always comes from content the editor controls: an image without dimensions, an embed iframe, or a promo banner injected after the fact. Emit intrinsic dimensions from the CMS asset metadata.
// components/RichImage.jsx — width/height come from the CMS asset record
const aspectStyle = (a) => ({ aspectRatio: a.width + ' / ' + a.height, maxWidth: '100%', height: 'auto' });
export function RichImage({ asset, priority = false }) {
return (
<img
src={asset.url}
alt={asset.alt ?? ''}
width={asset.width}
height={asset.height}
loading={priority ? 'eager' : 'lazy'}
fetchPriority={priority ? 'high' : 'auto'}
decoding="async"
style={aspectStyle(asset)}
/>
);
}
Validation: Load the page with network throttling set to Slow 3G and watch the layout. Nothing below the image may move as it decodes.
Step 5 — Cut the hydration cost that owns INP
INP is a main-thread problem. In a decoupled front end the main thread is usually busy hydrating components that were never interactive in the first place.
// app/product/[id]/page.jsx — server component by default
import { AddToCart } from './AddToCart'; // the only client component
const descriptionHtml = (p) => ({ __html: p.descriptionHtml });
export default async function Product({ params }) {
const product = await cms.products.get(params.id);
return (
<article>
<h1>{product.title}</h1>
<div dangerouslySetInnerHTML={descriptionHtml(product)} />
<AddToCart id={product.id} />
</article>
);
}
Validation: Record a performance trace, tap the primary control, and confirm the longest task between input and next paint is under 200 ms.
Step 6 — Gate the fix so it cannot regress
Every improvement here is one careless import away from being undone. Lock it behind a budget in CI.
{
"ci": {
"collect": { "numberOfRuns": 3 },
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }]
}
}
}
}
Validation: Push a branch that adds a dimensionless image and confirm the job fails on the layout-shift assertion.
Framework-Specific Configuration
The three major headless front ends expose different levers for the same three metrics. The table below is the short form; the sections that follow give the working configuration.
Next.js App Router
// app/blog/[slug]/page.jsx
import Image from 'next/image';
export default async function Post({ params }) {
const post = await cms.posts.get(params.slug);
return (
<article>
<Image
src={post.hero.url}
alt={post.hero.alt}
width={post.hero.width}
height={post.hero.height}
priority
sizes="(max-width: 768px) 100vw, 800px"
/>
<h1>{post.title}</h1>
</article>
);
}
SEO impact: priority emits a <link rel="preload"> in the document head, so the hero starts downloading in the same round trip that delivered the HTML instead of waiting for the layout pass.
Nuxt
<script setup>
const { data: post } = await useAsyncData('post', () => $cms.posts.get(route.params.slug));
</script>
<template>
<NuxtImg
:src="post.hero.url"
:alt="post.hero.alt"
:width="post.hero.width"
:height="post.hero.height"
preload
sizes="sm:100vw md:800px"
/>
</template>
SEO impact: useAsyncData resolves during server rendering, so the image URL is in the served HTML and the preload attribute can act on it. Pair it with the route rules described in configuring routeRules for hybrid rendering in Nuxt.
SvelteKit
// src/routes/blog/[slug]/+page.js
export const prerender = true;
export async function load({ params, fetch }) {
const res = await fetch(`/api/posts/${params.slug}`);
return { post: await res.json() };
}
<svelte:head>
<link rel="preload" as="image" href={data.post.hero.url} fetchpriority="high" />
</svelte:head>
<img
src={data.post.hero.url}
alt={data.post.hero.alt}
width={data.post.hero.width}
height={data.post.hero.height}
/>
SEO impact: Prerendering removes the API round trip from the critical path entirely, which is usually worth more milliseconds than any image optimisation. The prerender flag is covered in depth in prerendering routes for SEO in SvelteKit.
HTTP Headers & CDN Directives Reference
| Header | Required value | Rationale |
|---|---|---|
Cache-Control |
public, max-age=0, s-maxage=3600, stale-while-revalidate=86400 |
Lets the edge answer instantly while revalidating in the background, which removes CMS latency from TTFB |
Link |
<hero.avif>; rel=preload; as=image; fetchpriority=high |
Starts the LCP image download before the HTML parser reaches the <img> element |
Timing-Allow-Origin |
* for your image CDN |
Without it, RUM attribution cannot read resource timings and LCP sub-parts come back empty |
Content-Encoding |
br for HTML and JS |
Brotli on the hydration bundle directly reduces the blocking time that dominates INP |
Vary |
Accept on image responses |
Prevents a cached AVIF being served to a client that cannot decode it, which would force a re-request mid-render |
Priority |
u=0 on the document response |
Signals the highest urgency for the HTML that everything else depends on |
Validation Protocol
Confirm the hero is discoverable from the server HTML
curl -s https://example.com/blog/my-post \
| grep -oE '<link[^>]*rel="preload"[^>]*as="image"[^>]*>'
# Expect exactly one preload for the hero image
Measure the layout stability of CMS media
npx lighthouse https://example.com/blog/my-post \
--only-audits=cumulative-layout-shift,layout-shift-elements \
--form-factor=mobile --output=json --quiet \
| jq '.audits["layout-shift-elements"].details.items'
Confirm the interaction budget
npx lighthouse https://example.com/product/1234 \
--only-audits=total-blocking-time,mainthread-work-breakdown \
--output=json --quiet | jq '.audits["total-blocking-time"].numericValue'
# Under 200 ms on a mid-tier mobile profile
Compare lab against field
# If lab is green and field is red, your throttling profile is unrepresentative
diff <(jq -r '.lcp' field.json) <(jq -r '.audits["largest-contentful-paint"].numericValue' lab.json)
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| LCP good in lab, poor in field | Lab runs hit a warm edge cache; real users hit cold regions | Widen cache coverage and check regional TTFB before touching the front end |
| LCP dominated by resource load delay | Hero image URL only known after client-side data fetch | Prerender the route or emit a Link: rel=preload header from the edge |
| CLS spikes only on editorial pages | Rich-text embeds render without reserved height | Wrap embeds in an aspect-ratio box driven by CMS metadata |
| CLS appears after fonts load | Web font swaps with different metrics | Use size-adjust and font-display: optional on the fallback |
| INP fine on desktop, poor on mobile | Hydration cost scales with CPU, not bandwidth | Convert non-interactive subtrees to server components or islands |
| Vitals fine, crawl rate falling | Origin slow only for uncached crawler paths | Check bot-path TTFB separately — see parsing CDN logs for Googlebot crawl waste |
How the Fixes Interact
Optimising one metric can quietly damage another, and the interactions are predictable enough to plan around. Aggressive lazy-loading helps the initial payload but delays the hero and pushes LCP out. Code-splitting improves INP but adds round trips that can extend the render delay. The diagram below shows which moves reinforce each other and which trade off.
Two rules follow from this map. Never lazy-load anything in the initial viewport, because the technique that helps every other image actively harms the one that defines your LCP. And never split a bundle so finely that the request waterfall costs more than the parse time it saved — measure the split, do not assume it.
Pages in This Section
- Fixing LCP on Headless Product Pages — attribute the LCP sub-parts on a commerce template and remove the API round trip from the critical path
- Eliminating CLS From CMS-Driven Media — reserve space for editor-controlled images, embeds, and banners before they arrive
- Reducing INP on Hydration-Heavy Routes — shrink the main-thread work between a visitor’s first tap and the next paint
Frequently Asked Questions
Do Core Web Vitals affect indexing in a headless site? Not directly — a slow page is still crawled and indexed. The indirect effect is real, though: slow origin responses reduce the crawl rate Google will sustain, and page experience acts as a tie-breaker among comparably relevant results. In decoupled stacks the same latency that hurts the vitals also hurts crawl throughput, so the two problems usually share one root cause in the edge caching layer.
Why does a headless rebuild often regress LCP? Because the largest element becomes dependent on a content API round trip. In a monolith the hero markup and image URL sat in the template; in a decoupled build the page waits on a CMS query before it can even discover the image to preload. Statically rendering the route, or emitting the preload hint at the document level, removes that dependency.
Is Lighthouse CI enough to protect the vitals? It is necessary but not sufficient. Lighthouse CI catches structural regressions — a doubled bundle, an image that lost its dimensions — deterministically and before merge. It cannot reproduce real device, network, and interaction distributions, so pair it with field monitoring per template group and treat divergence between lab and field as evidence your lab profile is unrepresentative.
Should I optimise vitals before or after fixing indexation? Indexation first. A page that is not indexed gains nothing from a faster paint, and several indexation fixes — prerendering routes, caching at the edge, cutting redirect hops — improve the vitals as a side effect. Once coverage is stable, the vitals work has a measurable audience.
Part of: Headless Architecture & Rendering Strategy Fundamentals
Related
- Edge Caching Behavior for SEO — the TTFB layer that determines how much of your LCP budget is already spent before rendering starts
- Framework-Specific Rendering Tradeoffs — how each rendering mode changes the vitals profile of a route
- Debugging & Diagnostics for Headless Rendering — the render-blocking and pipeline tooling that produces these measurements
- ISR vs SSG vs CSR Routing — pick the rendering mode before tuning any metric on the route