nginx CLS via insert_image_dimensions
nginx itself is fast; CLS is what nginx serves, not what nginx does. A 0.2+ CLS on mobile with the failing element a content image missing width/height is the classic shape, and auditing every static HTML file (or every template across every generator) is a recurring tax. Rewrite <img> tags at the HTML layer with insert_image_dimensions via ModPageSpeed (ngx_pagespeed) and stack CSS aspect-ratio as a backstop.
What CLS measures
Cumulative Layout Shift records every unexpected movement of visible content between load start and the moment the page becomes interactive. Each shift is scored by the fraction of the viewport that moved multiplied by the distance moved. The session window with the highest sum becomes the CLS value. A “good” score per web.dev/cls is under 0.1; “poor” begins at 0.25.
Shifts within 500 ms of, and attributable to, a user input don’t count — a shift that happens to land in the window but wasn’t caused by the click still scores. PSI shows the field 75th-percentile CLS from CrUX; Lighthouse shows a single lab run. The two often disagree for static-HTML sites: the lab serves the HTML faster than real users do, so font swaps and lazy-image jumps don’t fire in lab but do in field. Trust the field number.
The most common CLS failures on nginx-served sites
This is the CMS-agnostic post in the series. nginx fronts WordPress, Magento, ASP.NET Core, static-site-generator output, and hand-written HTML; CLS patterns vary, but three causes dominate when nginx is serving HTML directly (no upstream app).
Static HTML files have <img> tags without dimensions. Hand-built sites, Jekyll/Hugo/Eleventy output where the templates don’t pipe images through a dimension-aware shortcode, and Markdown-converted content (Pandoc, common Node converters) all routinely produce <img src="..."> with no width or height. nginx serves the file as-is. The browser allocates zero space, paints text, jumps the layout when bytes arrive. The shift is exactly the rendered image height.
Stale HTML cached by nginx (or a CDN in front of it) references images at new dimensions. Common pattern on a content site: image gets re-edited, replaced at the same URL or under a new hash, but the HTML in cache still embeds the old dimensions (or no dimensions). nginx’s proxy_cache or a Cloudflare/Fastly edge serves the stale HTML; the loaded image has different dimensions; layout shifts. This compounds when the cache TTL is long and the image refresh path is faster than the HTML refresh path.
Web fonts and third-party iframes shift content after initial paint. Self-hosted fonts loaded with font-display: swap re-flow every text block when the font swaps. Embedded iframes (YouTube, Twitter/X, Instagram) start at zero height in the parent’s HTML, expand when their content loads, push everything below. nginx is not responsible for any of this — it’s serving the HTML the author wrote — but it’s the layer where the rewrite fix lands.
Where to look in the page first
Run PSI on the affected URL: https://pagespeed.web.dev/analysis?url=https://example.com/. The “Avoid large layout shifts” diagnostic lists the specific elements and their score contribution.
In Chrome DevTools: Performance panel → Settings cog → enable “Web Vitals” and “Layout Shift Regions”. Throttle network to “Slow 4G”. Reload. Each shift is a red rectangle in the overlay; hover the shift markers in the Timings track to identify the DOM node.
Static-site-specific diagnostics:
grep -L 'width=' *.html | head -20finds HTML files that lack any dimension attribute. Useful to identify how widespread the problem is across a generator’s output.curl -s https://example.com/article/ | grep -oE '<img [^>]+>' | grep -v 'width='prints<img>tags missing dimensions on a specific URL.
For repeatable measurement, install web-vitals and load it from a dev-only <script> tag: onCLS(console.log, { reportAllChanges: true }). The console prints the worst element per session.
Step 2: Set image dimensions server-side with insert_image_dimensions
CLS is the metric ModPageSpeed is most effective on, and on nginx the integration is the headline configuration. Three filters do most of the work.
insert_image_dimensions inspects every <img> tag in the outgoing HTML, fetches the underlying file (cached after first hit; works with same-origin paths and same-domain absolute URLs), reads actual pixel dimensions, and inserts matching width and height attributes. Combined with a single CSS rule on the site (img { max-width: 100%; height: auto; }), the browser computes the correct aspect-ratio box from the attributes and reserves the layout slot. The image-jump shift disappears for every page on the site — including content that was hand-authored, Markdown-converted, or pasted from a CMS that doesn’t track dimensions.
lazyload_images reserves dimensioned placeholders for offscreen images while deferring the load. Because the placeholder is sized via the freshly-inserted width/height attributes, no shift fires when the real image swaps in.
prioritize_critical_css inlines above-the-fold CSS and defers the rest, shrinking the FOUC window during which font swaps and layout-affecting CSS arrive. On sites with large external stylesheets (Bootstrap, Tailwind, custom framework CSS), this is the second-largest CLS contributor after un-sized images.
extend_cache content-hashes asset URLs (images, CSS, JS) and serves them with long-lived cache headers. For failure mode #2 (stale HTML referencing newer images), extend_cache prevents the mismatch in the first place — the HTML references a hashed URL that resolves to a specific image bytes, so a re-edit produces a new hash and the HTML must be updated to reference it.
Why a rewriter beats per-template audits: nginx fronts every kind of upstream, and the rewriter sits between the upstream and the client. The fix applies uniformly regardless of whether the HTML was generated by Hugo, hand-typed, or pulled from a legacy CMS. The rewriter is a one-time configuration; per-template audits are a recurring tax.
What this does not fix: third-party iframes (the rewriter doesn’t know how tall a YouTube embed will render), web-font swap shifts (browser concern), and any content injected by JavaScript after the initial response. These need CSS-level reserved space or different markup, covered next.
Tuning beyond ngx_pagespeed
- Add a global CSS backstop on
<img>:img { max-width: 100%; height: auto; }. Withwidth/heightattributes now present via the rewriter, every modern browser computes the aspect-ratio box from those attributes directly — noaspect-ratio: attr(...)rule needed (CSSattr()insideaspect-ratiois not reliably supported as of 2026). - Self-host fonts via
font-display: optionalinstead ofswap.optionalgives the web font 100 ms to arrive; if it misses, the fallback stays for the whole page lifetime. Counterintuitively this reduces CLS because no swap fires after initial paint. - For embedded iframes (YouTube, Twitter, Instagram), wrap them in a fixed-aspect-ratio container:
<div style="aspect-ratio: 16 / 9; max-width: 100%;">
<iframe src="https://www.youtube.com/embed/..." style="width: 100%; height: 100%;"></iframe>
</div>
- Ship dimensions at build time where the generator supports it: Astro’s
<Image>component, Hugo’simage_processing, Eleventy’seleventy-imgall setwidth/heightautomatically. The rewriter then becomes a no-op (it only fills missing attributes); fixes for upstream regressions still land via the rewriter. - For content-hashed asset URLs, enable
extend_cacheand set a longCache-Controlheader in nginx for hashed paths:location ~ "\.[a-f0-9]{8,}\." { expires 1y; add_header Cache-Control "public, immutable"; }. The HTML is short-cached, the assets are long-cached; the mismatch goes away. - Set
gzip_static onso pre-compressed.html.gzfiles are served when present; smaller HTML means shorter parse window and tighter CLS budget.
Confirming the shift is gone
- Re-run PSI. “Avoid large layout shifts” should drop to zero shifted elements or list only third-party embeds you haven’t wrapped yet.
- DevTools → Performance → reload trace with Slow 4G throttle. The Layout Shift Regions overlay should be empty across content.
- After 28 days, Search Console → Core Web Vitals report. The URL group should move from “Needs improvement” to “Good”.
- Watch the CrUX field CLS in PSI’s panel. Static-site lab CLS is often 0 because the lab loads files instantly; field CLS reveals the font-swap and lazy-image shifts real users hit.
A drop-in nginx snippet
# nginx — minimal config to address CLS on generic / static / hand-built sites
pagespeed on;
pagespeed FileCachePath /var/cache/ngx_pagespeed;
pagespeed RewriteLevel CoreFilters;
pagespeed EnableFilters insert_image_dimensions;
pagespeed EnableFilters lazyload_images;
pagespeed EnableFilters prioritize_critical_css;
pagespeed EnableFilters extend_cache;
pagespeed FetchHttps enable;
Then a single CSS rule in your site’s stylesheet to let modern browsers compute aspect-ratio boxes from the attributes:
img { max-width: 100%; height: auto; }
When this doesn’t work
- If
insert_image_dimensionsdoesn’t insert attributes, the rewriter cannot fetch the image — usually because the image is on a different origin andpagespeed Domaindoesn’t whitelist it, or because the origin returns aContent-Typeother than an image MIME. Check the rewriter’s debug log (pagespeed Statistics on) and confirm the fetch is happening. - If CLS attribution still names content images after the fix, the images may be inserted by JavaScript after the initial response (a lightbox gallery, a content-loader script). The rewriter only sees server-side HTML. Fix in CSS via
aspect-ratioon the container, or pre-render the first slide server-side. - If CLS attribution names a third-party iframe, no rewriter knows the embed’s intrinsic height. Wrap it in a fixed-aspect container.
- If you see CLS in the lab but not in CrUX, the lab is exposing a race condition real users do not hit (CPU throttling triggers font-swap timing that doesn’t fire in production). Trust the field number; do not over-tune to lab.
Related
- How to fix LCP on nginx
- How to fix INP on nginx
- How to fix CLS on WordPress
- Server-side critical CSS on nginx
- ModPageSpeed filter reference
ModPageSpeed runs as an nginx or Apache module; a 14-day trial starts from /pagespeed_global_admin (card-at-start via FastSpring). See license terms.
Read next
-
ASP.NET Core CLS: pre-size async holes
How to fix CLS on ASP.NET Core: pre-size async view components, persist image dimensions, reserve banner space, rewrite img tags via middleware.
-
Magento CLS starts with Fotorama
How to fix CLS on Magento: tame Fotorama gallery jumps, pre-size mini-cart and private-content blocks, rewrite img dimensions at the server layer.
-
WooCommerce CLS: lock the gallery
How to fix CLS on WooCommerce: stop the gallery shift, lock variation swap dimensions, reserve cross-sell space, and rewrite img tags at the server.