Skip to main content
ModPageSpeed 2.0: AVIF, WebP, and critical CSS — up to 69% less page weight on the live demo

Fix LCP on nginx: 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 mod_pagespeed 1.15 (the ngx_pagespeed module) so the same recipe applies regardless of what backend nginx is fronting.

This guide is part of our Core Web Vitals series.

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. The “good” line sits at 2.5 s; past 4 s is “poor”. The field figure comes from Chrome’s CrUX dataset (real users over the trailing 28 days), the lab figure from Lighthouse and PageSpeed Insights — and they often disagree because the network path real users take is slower than localhost. 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

mod_pagespeed 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 mod_pagespeed 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 — reduce TTFB before it delays LCP walks through the server-layer wins to try first.
  • The LCP element is a third-party embed. Lazy-loaded YouTube iframes and CMP-injected video posters often become the LCP candidate. mod_pagespeed 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.

mod_pagespeed 1.15 runs as an nginx or Apache module. It optimizes out of the box. Production use requires a commercial license — but the software never locks you out (unlicensed installs keep optimizing and add an X-PageSpeed-Warn: unlicensed header). See pricing and license terms.

Like this kind of writeup?

We write about how mod_pagespeed and ModPageSpeed actually work, and what we learn shipping them. Get the next post by email.

Read next