Skip to main content
ModPageSpeed 2.0 and mod_pagespeed 1.1 — Now available

nginx LCP: transport, not content

By Otto van der Schaaf

core-web-vitals lcp nginx

The nginx-side LCP failures cluster around transport, not content. Three of them keep showing up: HTML on the slow path while images sit cacheable behind it, on-the-fly gzip serializing CPU per response, and HTTP/1.1 head-of-line blocking that queues the LCP image behind CSS. The fix is to get the transport layer out of the way (HTTP/2, pre-compressed assets, TLS resumption), then route image and CSS work through ModPageSpeed (the ngx_pagespeed module) so the same recipe applies regardless of what backend nginx is fronting.

What LCP measures

LCP — Largest Contentful Paint — is the render time of the largest above-the-fold element on a page. On most sites that is a hero image, video poster, or H1. Google considers anything under 2.5 s “good”; anything over 4 s is “poor”. The field measurement comes from Chrome’s CrUX dataset (real users over the last 28 days); the lab measurement comes from Lighthouse and PageSpeed Insights. The two often disagree because real users are slower than your developer machine. See web.dev/articles/lcp for the canonical definition.

The most common LCP failures on nginx

These three keep showing up on nginx-fronted sites, independent of the backend:

  1. Origin is fast, but the static/dynamic split leaks. A typical try_files $uri @app; pattern routes static files from disk and falls through to an upstream for dynamic content. The static side is fine — the dynamic side ships responses without Cache-Control: s-maxage, so the CDN treats HTML as uncacheable and re-fetches it. LCP suffers because the HTML is on the slow path and the LCP image (cacheable) waits behind it.

  2. gzip on; is set but gzip_static off; is the default. nginx compresses on the fly on every request — 5–30 ms of CPU per response — instead of serving pre-compressed .gz files from disk. On a busy server, this serializes behind the worker pool and the LCP image’s first byte is delayed.

  3. HTTP/1.1 head-of-line blocking on a single connection. Sites still on HTTP/1.1 (no listen 443 ssl http2;) push all assets through one or two parallel connections; the LCP image queues behind CSS and JS that the browser opens first. Even with the rewriter optimizing bytes, the transport bottleneck eats the win.

What the transport layer reveals

Before changing anything:

  • Run PageSpeed Insights on the affected URL: https://pagespeed.web.dev/analysis?url=<URL>. Read the Largest Contentful Paint element breakdown — the four phases (TTFB, resource load delay, resource load duration, element render delay) tell you which layer to fix.
  • curl -sI -H 'Accept-Encoding: gzip, br' https://<host>/index.html — check for content-encoding: br (Brotli) or gzip on the document, and Cache-Control headers. Missing compression on the document is failure mode #2; missing s-maxage is failure mode #1.
  • curl -sI --http2 -o /dev/null -w '%{http_version}\n' https://<host>/ — should print 2. If it prints 1.1, HTTP/2 isn’t enabled (failure mode #3).
  • Chrome DevTools → Network panel → reload, look at the Waterfall column. HTTP/1.1 shows a clear “6-connection limit” stagger; HTTP/2 shows requests landing in parallel. The LCP image should be in the first wave of requests, not waiting on CSS/JS.
  • For repeatable measurement across config changes: WebPageTest scripted runs on a fixed connection profile. WebPageTest’s “Connection View” diagnoses transport problems faster than Lighthouse.

Output you’re hunting for: protocol, compression, cache headers, and the LCP element’s timing breakdown. If TTFB is the long pole, the rewriter can’t help; fix the backend first. If resource load duration is the long pole, the rewriter is the right tool.

Step 2: Rewrite images and inline critical CSS with ngx_pagespeed

ModPageSpeed loads as an nginx module (ngx_pagespeed). It rewrites HTML on the way out and serves optimized image variants from a shared cache. nginx is the canonical deployment target, so this is the cleanest configuration in the matrix. The filters that move LCP here:

  • recompress_images + convert_jpeg_to_webp — re-encodes hero images at a configured quality and transcodes to WebP for browsers that advertise support.
  • inline_preview_images — ships a sub-1 KB LQIP placeholder inline in the HTML for the hero, so the first paint shows something during the network wait.
  • prioritize_critical_css — extracts above-the-fold rules and inlines them into the document <head>, defers the rest. Kills the multi-stylesheet <head> chain that backend-emitted HTML usually carries.
  • hint_preload_subresources — emits Link: rel=preload response headers for resources the rewriter has identified as needed for above-the-fold rendering. The browser starts fetching them before the document body parses.
  • lazyload_images — defers offscreen image loading; this is marked “Test first” in the filter reference and changes load order. Enable it for image-heavy pages, but keep the LCP image out of the lazy set — the rewriter’s beacon-based detection identifies above-the-fold images automatically; verify with ?PageSpeedFilters=+debug.

Minimal nginx config:

pagespeed on;
pagespeed FileCachePath /var/cache/ngx_pagespeed;
pagespeed RewriteLevel CoreFilters;
pagespeed EnableFilters recompress_images;
pagespeed EnableFilters convert_jpeg_to_webp;
pagespeed EnableFilters inline_preview_images;
pagespeed EnableFilters prioritize_critical_css;
pagespeed EnableFilters hint_preload_subresources;
# Tune image recompression
pagespeed ImageRecompressionQuality 82;

Place these directives in the server { } block. The rewriter runs on responses returned through the location block, regardless of whether they come from a proxy_pass, fastcgi_pass, or try_files path.

After enabling, re-run PSI. LCP attribution should show the rewritten WebP variant being served and the resource load duration should drop. If LCP element render delay stays high, the long pole is the CSS chain or the transport — see the transport-layer tuning below.

Transport-layer tuning

What you do at the nginx layer to stack additional wins:

  • Enable HTTP/2: listen 443 ssl http2; in the server block. HTTP/3 if available — requires nginx ≥ 1.25 with QUIC compiled in.
  • gzip_static on; plus ship pre-compressed .gz and .br files alongside the originals. The ngx_brotli module is widely packaged; enable both with brotli_static on;.
  • ssl_session_cache shared:SSL:10m; and ssl_session_timeout 1d; — TLS resumption saves roughly 80 ms on repeat visits, especially on mobile networks where RTT dominates.
  • For dynamic responses, set add_header Cache-Control "public, s-maxage=60, stale-while-revalidate=30"; so the CDN can serve stale HTML while revalidating in the background.
  • Audit the loaded config: nginx -T | grep -E 'gzip|http2|brotli|ssl_session'. The file you edit isn’t necessarily the file that’s loaded if there are include glob mismatches or a stale package config under /etc/nginx/conf.d/.

Confirming the transport win

  1. curl -sI --http2 -H 'Accept: image/webp,*/*' https://<host>/<hero-url> — confirm content-type: image/webp and http/2 200. Confirm cache-control is set as configured.
  2. Open the page with ?PageSpeedFilters=+debug — the response includes HTML comments showing which filters ran on which assets. Confirm the hero is in the rewritten set and is not lazy-loaded.
  3. Re-run PSI on the same URL. The LCP element timing should show smaller resource load duration (WebP + compression) and smaller resource load delay (HTTP/2 + preload).
  4. After 28 days, check Search Console → Core Web Vitals report. The URL group should move from “Needs improvement” to “Good”. Watch field data, not just lab.

The nginx snippet

# nginx — minimal config to address LCP, stack-agnostic
server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    gzip on;
    gzip_static on;
    # ngx_brotli module:
    brotli on;
    brotli_static on;

    pagespeed on;
    pagespeed FileCachePath /var/cache/ngx_pagespeed;
    pagespeed RewriteLevel CoreFilters;
    pagespeed EnableFilters recompress_images;
    pagespeed EnableFilters convert_jpeg_to_webp;
    pagespeed EnableFilters inline_preview_images;
    pagespeed EnableFilters prioritize_critical_css;
    pagespeed EnableFilters hint_preload_subresources;
    pagespeed ImageRecompressionQuality 82;

    location / {
        try_files $uri @app;
        add_header Cache-Control "public, s-maxage=60, stale-while-revalidate=30";
    }
}

The full directive reference lives in the filter reference. For the install steps on nginx (Debian, Ubuntu, RHEL/AlmaLinux), see installation as an nginx module.

When this doesn’t work

Cases where ModPageSpeed alone isn’t enough on nginx:

  • TTFB is the long pole. If curl -w '%{time_starttransfer}\n' shows over 1 s, no amount of HTML rewriting fixes LCP — the browser is already 1 s in when the first byte arrives. Fix the backend (PHP-FPM tuning, Node process count, database query plan) before the rewriter.
  • The LCP element is a third-party embed. Lazy-loaded YouTube iframes and CMP-injected video posters often become the LCP candidate. ModPageSpeed cannot rewrite third-party iframe contents. Use a facade pattern; the LCP becomes your own static image.
  • The HTML is built by JavaScript on the client. Single-page apps (React, Vue, Svelte hydrating from a thin shell) construct the LCP element after page load. The nginx-layer rewriter doesn’t see the runtime DOM. SPA LCP work moves to build time (Next.js <Image>, route-level code-splitting) or to a server-side rendering / pre-rendering layer.
  • The LCP is text and the font is the long pole. Web fonts loaded with font-display: swap cause a render delay on the H1 LCP. The fix is font-display: optional, self-hosting the font, and preloading with <link rel="preload" as="font" crossorigin>. The rewriter doesn’t manage your font loading strategy.

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