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-vitals measurements 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.

Where each Core Web Vital breaks on the headless request timeline A single request timeline divided into four stages — origin response, HTML paint, media arrival, and hydration — with LCP attached to the first two stages, CLS to media arrival, and INP to hydration. request timeline CMS API + TTFB HTML paint media arrives hydration LCP — fetch and render path CLS INP prerender or cache preload hero reserve dimensions island hydration Fix the stage that owns the metric — not the metric itself

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.

Framework levers per Core Web Vital A three-by-three matrix mapping Next.js, Nuxt, and SvelteKit to the primary configuration lever each offers for LCP, CLS, and INP. Next.js Nuxt SvelteKit LCP next/image priority NuxtImg preload prerender + preload CLS intrinsic sizing aspect-ratio props CSS aspect box INP server components lazy hydration smaller runtime Primary lever per framework and metric

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.

Interaction map of Core Web Vitals optimisations Six optimisation techniques on the left connected to the three metrics on the right, distinguishing techniques that help a metric from those that trade against another. technique metric affected Prerender route Preload hero image Blanket lazy-load Intrinsic dimensions Island hydration Aggressive splitting LCP CLS INP helps trades off — verify in field data

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

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