Reducing CLS with Headless CMS Image Placeholders
Cumulative Layout Shift (CLS) in headless deployments comes from asynchronous asset resolution: content APIs return image URLs with no guaranteed intrinsic dimensions, so the browser allocates zero vertical space at parse time, then forces a synchronous reflow once the request completes. The fix is deterministic space reservation before hydration begins. This guide belongs to Image Optimization Pipelines for CMS Assets.
The Dimension Gap in Decoupled Payloads
The primary failure is a GraphQL or REST query that omits width and height. Unless those fields are projected, the browser has no geometry to compute layout boxes. Even when present, static pixel values break under responsive breakpoints, and editors uploading assets without standardized aspect ratios make it worse — especially alongside aggressive lazy loading. Treat image metadata as a required part of the content schema, not an optional attachment.
Deterministic Space Reservation
Reserve layout space before the network request completes. Query intrinsic dimensions with the asset URL and apply CSS aspect-ratio with width: 100% for proportional scaling across viewports. This removes the vertical jump on fetch.
.cms-image-wrapper {
position: relative;
width: 100%;
aspect-ratio: var(--img-width) / var(--img-height);
background-color: var(--placeholder-bg, #f0f0f0);
overflow: hidden;
}
.cms-image-wrapper img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
Avoid hiding images with opacity: 0 until a load event fires. It makes images invisible without JavaScript and delays when the browser considers the LCP image painted; the placeholder behind the image already smooths the transition.
Injecting the dimensions as CSS custom properties at render time lets the browser compute container height immediately; absolute positioning scales the <img> without reflow when the payload arrives.
Perceptual Placeholders at Ingestion
A static fallback color looks wrong across locales and high-contrast imagery. Generate perceptual hashes at the ingestion layer and decode them into a lightweight image before the optimized asset resolves, bridging initial paint and final render. Decode on the server where possible, so the placeholder is part of the HTML and appears with the first paint. Folding this into your Image Optimization Pipelines for CMS Assets gives every asset a compact visual signature.
import { decode } from 'blurhash';
export function generatePlaceholderDataURL(
hash: string,
width: number = 32,
height: number = 32
): string {
const pixels = decode(hash, width, height);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
const imageData = ctx.createImageData(width, height);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
A blurhash string runs 20–30 characters, so it embeds directly in CMS JSON. Decoding is synchronous on the main thread but completes in under 2ms for a 32×32 grid, so it doesn’t block rendering.
Generating Placeholders at Upload
The cheapest time to compute placeholder data is once, when the asset is uploaded or published. A small worker, triggered by the CMS’s asset webhook, downloads a small rendition of the image, computes its dominant colour and a blurhash, and writes both back to the asset’s metadata fields through the management API. From then on, every query that returns the image returns its placeholder too, and no page render or browser ever has to compute it. Some CMS image APIs already return a dominant colour or palette in their metadata, which covers the simpler case without any worker. Backfill existing assets with a one-off script using the same code, in batches that respect the API’s rate limits.
Multilingual Variants and Fallbacks
Locale variants of an image can have different crops and dimensions, so each variant needs its own width, height and placeholder, stored with the variant rather than the master asset. When a locale falls back to the default variant, take the dimensions from the variant actually served. Validate in the CMS that every variant has dimensions, and never guess dimensions at render time; a guessed 16:9 box for a 4:5 portrait image shifts as badly as no box at all.
A Server-Rendered Image Component
Everything the browser needs to reserve space is known when the page renders: dimensions, a placeholder and whether the image is the LCP candidate. A server component renders it all into the HTML, with no client-side validation or decoding.
// components/cms-image.tsx (server component)
import { blurhashToDataUrl } from "@/lib/blurhash-server"; // decodes with sharp or a pure-JS PNG encoder
import { imageUrl } from "@/lib/images";
interface CmsImageProps { asset: { url: string; width: number; height: number; alt?: string; blurhash?: string; color?: string }; sizes: string; priority?: boolean }
export async function CmsImage({ asset, sizes, priority = false }: CmsImageProps) {
const placeholder = asset.blurhash ? await blurhashToDataUrl(asset.blurhash, 16, 16) : undefined;
const widths = [480, 768, 1024, 1280, 1600];
return (
<div
className="cms-image-wrapper"
style={{
["--img-width" as string]: asset.width,
["--img-height" as string]: asset.height,
backgroundColor: asset.color ?? "var(--placeholder-bg)",
backgroundImage: placeholder ? `url(${placeholder})` : undefined,
backgroundSize: "cover",
}}
>
<img
src={imageUrl(asset, { width: 1280 })}
srcSet={widths.map((w) => `${imageUrl(asset, { width: w })} ${w}w`).join(", ")}
sizes={sizes}
width={asset.width}
height={asset.height}
alt={asset.alt ?? ""}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : undefined}
decoding="async"
/>
</div>
);
}
React escapes the alt text and attributes, which string templates do not. A 16 by 16 placeholder encoded as PNG is a few hundred bytes; for pages with many images, a dominant colour, which most CMS image APIs return in metadata, is cheaper still and good enough for thumbnails.
Verifying Reserved Space
Reserved boxes are easy to verify automatically, which makes them a good candidate for CI. Render each template with fixture content and, in a headless browser with images blocked, measure every image’s bounding box: each should have a non-zero height that matches its final aspect ratio. Blocking images isolates layout from loading, so any image that collapses to zero height has no reserved space. In field data, the web-vitals attribution for CLS reports the element responsible for the largest shift; if it is an image or an element just below one, the reservation is missing or wrong for that template. Pair both checks with a query lint that flags GraphQL or REST queries requesting image URLs without width and height, which catches the most common root cause at the source.
Gotchas & Edge Cases
- Dimensions of the original, not the rendition. Use the source asset’s dimensions for the aspect ratio; renditions are scaled versions with the same ratio unless cropped.
- Cropped renditions. When the image API crops to a different aspect ratio, reserve the crop’s ratio, not the original’s.
- Placeholder weight. Base64 placeholders inflate HTML. Skip them for small images and pages with dozens of thumbnails.
- CSS overriding height. A global
img { height: auto }is fine; a rule setting fixed heights withoutaspect-ratiocan reintroduce shifts at some breakpoints.
Worked Example
An e-commerce listing page rendered product cards whose images came from the CMS without dimensions, because the listing query did not request them. Mobile CLS was 0.31. Adding width and height to the query and rendering them as attributes cut CLS to 0.04; adding aspect-ratio boxes with the dominant colour from the image API’s metadata brought it to 0.03 and made the grid look complete before images arrived. The query change took ten minutes; finding that the query was the cause took a day.
Rollout Checklist
- Make width, height and alt text required on image fields, per locale variant.
- Request dimensions in every query that returns images.
- Render
widthandheightor an aspect-ratio box on every image. - Add colour or blurhash placeholders for large images, decoded on the server.
- Do not fade in LCP images with opacity transitions.
- Check CLS per template in field data after changes.
Frequently Asked Questions
Are width and height attributes enough?
For simple layouts, yes: browsers derive the aspect ratio from them. Wrappers with aspect-ratio help when images are cropped or positioned with object-fit.
Blurhash or dominant colour?
Dominant colour for thumbnails and grids, where it is nearly free, and blurhash or a tiny preview for large heroes where the wait is long enough to notice.
Can placeholders be generated on the fly?
Yes, from the image API or at upload time. Generating them at upload and storing them with the asset is cheapest, since the work happens only once per asset version.
Do SVG images need dimensions?
Yes, or a viewBox plus explicit sizing in CSS for the element that contains them, otherwise they can render at unexpected sizes before styles apply.
Why did CLS get worse after adding lazy loading?
Lazy images without reserved space load as the reader scrolls, and each one shifts content below it. Reserve space for every lazy image; lazy loading and dimensions belong together and should be introduced in the same change.