Largest Contentful Paint (LCP) Optimisation: A Code-Level Checklist

Largest Contentful Paint measures how long it takes for the largest visible content element — usually a hero image or an H1 heading — to finish rendering in the viewport. Google's recommended threshold is under 2.5 seconds on the 75th percentile of real user loads. Most sites fail this not because of a single bottleneck, but because of a chain of preventable delays, each adding a few hundred milliseconds.

This guide works through every link in that chain with concrete code fixes: from the HTML you control directly, through Nginx configuration, to Drupal-specific optimisations. The goal is an LCP score that stays under 2.5s on a real mobile connection, not just on a throttled desktop lab test.


Diagnosing LCP Before Fixing It

Before touching code, understand what your actual LCP element is and which sub-metric is the bottleneck. LCP has four components:

  1. Time to First Byte (TTFB) — server response time
  2. Resource load delay — time between TTFB and when the LCP resource starts loading
  3. Resource load duration — how long it takes to download the LCP resource
  4. Element render delay — time between the LCP resource finishing and it appearing on screen

In Chrome DevTools: open Performance panel, record a page load, scroll to the LCP marker. The waterfall shows you which component dominates. Google's target distribution is roughly 40% / <10% / 40% / <10% across these four.

For field data (real users), use the PageSpeed Insights API or the Web Vitals JavaScript library:

<!-- Add to <head> for real-user LCP data -->
<script type="module">
import { onLCP } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js';

onLCP(metric => {
    const { value, attribution } = metric;
    console.log('LCP:', value, 'ms');
    console.log('LCP element:', attribution.lcpEntry?.element);
    console.log('URL:', attribution.url);
    console.log('Load time breakdown:', attribution.lcpEntry?.loadTime);
});
</script>

Fix 1: Reduce TTFB

If TTFB is over 600ms, every LCP sub-metric inherits that delay. Fix TTFB before anything else.

Enable Page Caching for Anonymous Users

In Drupal, anonymous page caching should always be on for public pages. Verify it is enabled:

drush php:eval "echo \Drupal::state()->get('system.maintenance_mode') ? 'maintenance' : 'live';"

In settings.php, confirm the page cache is not disabled:

// This line disables the page cache — ensure it is NOT in production settings.php
// $settings['cache']['bins']['page'] = 'cache.backend.null';

Nginx Microcaching for PHP-Generated Pages

For pages that cannot be fully cached (authenticated content, search results), Nginx microcaching stores responses for 1-2 seconds. This absorbs traffic spikes without serving genuinely stale content:

# nginx.conf — http block
fastcgi_cache_path /var/cache/nginx/drupal levels=1:2 keys_zone=drupal_cache:100m inactive=1m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";

# server block
fastcgi_cache drupal_cache;
fastcgi_cache_valid 200 301 302 1s;  # 1-second microcache
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_lock on;               # Stampede protection
add_header X-Cache-Status $upstream_cache_status;

# Skip cache for POST, logged-in users, and no-cache requests
fastcgi_no_cache $cookie_SESS $http_pragma $http_authorization;
fastcgi_cache_bypass $cookie_SESS $http_pragma $http_authorization;

Use a CDN for TTFB Reduction

A CDN serves cached responses from edge nodes close to the user. For a site hosted in Frankfurt, a user in Singapore sees 150-200ms of TTFB just from network latency. A CDN with an Asia-Pacific PoP reduces that to under 20ms. Cloudflare's free tier is sufficient for most sites.


Fix 2: Eliminate Resource Load Delay

Resource load delay is the time between the first byte arriving and the browser starting to download the LCP resource. The main cause: the LCP image is not discoverable in the initial HTML. The browser has to parse CSS, execute JavaScript, or decode a background image rule before it knows the image exists.

Put the LCP Image in the HTML, Not CSS

<!-- BAD: Browser only discovers this after CSS parses -->
<div class="hero" style="background-image: url('/hero.webp')"></div>

<!-- GOOD: Browser sees the src in the initial HTML parse -->
<img src="/hero.webp" alt="Hero" width="1200" height="600">

Add fetchpriority="high" to the LCP Image

The fetchpriority attribute tells the browser's preload scanner to treat this resource as high priority before the document is fully parsed:

<img
  src="/hero.webp"
  alt="Hero image"
  width="1200"
  height="600"
  fetchpriority="high"
  decoding="async"
>

Do not add loading="lazy" to the LCP image — this explicitly delays loading. Reserve lazy loading for images below the fold.

Preload the LCP Resource

If the LCP element is a CSS background image or a font, use a preload hint in <head> to make it discoverable early:

<link rel="preload"
      href="/hero.webp"
      as="image"
      fetchpriority="high"
      type="image/webp">

For responsive images, use the imagesrcset and imagesizes attributes to preload the correct size:

<link rel="preload"
      as="image"
      fetchpriority="high"
      imagesrcset="/hero-400w.webp 400w, /hero-800w.webp 800w, /hero-1200w.webp 1200w"
      imagesizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px">

Reduce Render-Blocking CSS

Every stylesheet in <head> is render-blocking. The LCP element cannot render until all CSS is downloaded and parsed. For large stylesheets, inline the critical CSS (the above-the-fold styles) and defer the rest:

<!-- Critical CSS inlined -->
<style>
  /* Minimal styles for above-the-fold content */
  .hero { width: 100%; height: auto; }
  h1 { font-size: 2rem; line-height: 1.2; }
</style>

<!-- Full stylesheet loaded non-blocking -->
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>

In Drupal, use the Asset Aggregation feature (Configuration → Performance) to combine all CSS files into one — reducing TCP connections. For critical CSS extraction, the critical_css contrib module can automate inlining per-route.


Fix 3: Reduce Resource Load Duration

Once the browser starts loading the LCP resource, the download time depends on file size and network conditions.

Use AVIF or WebP Image Formats

AVIF offers the best compression — typically 30-50% smaller than WebP for the same visual quality:

<picture>
  <source srcset="/hero.avif" type="image/avif">
  <source srcset="/hero.webp" type="image/webp">
  <img src="/hero.jpg"
       alt="Hero"
       width="1200"
       height="600"
       fetchpriority="high">
</picture>

In Drupal, the image_effects module and the ImageMagick toolkit can handle AVIF/WebP conversion. For simpler setups, use Nginx's image_filter module or a pre-conversion script:

# Convert all hero images to WebP and AVIF
for img in /var/www/html/web/sites/default/files/hero/*.jpg; do
    cwebp -q 80 "$img" -o "${img%.jpg}.webp"
    avifenc --min 25 --max 45 "$img" "${img%.jpg}.avif"
done

Nginx WebP Delivery Without Drupal Changes

If you cannot modify Drupal's image pipeline, serve WebP via Nginx when the client supports it:

# nginx.conf — http block
map $http_accept $webp_suffix {
    default         "";
    "~*webp"        ".webp";
}

# server block — static files location
location ~* \.(png|jpg|jpeg)$ {
    add_header Vary Accept;
    try_files $uri$webp_suffix $uri =404;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

This serves hero.jpg.webp instead of hero.jpg when a WebP file exists and the browser sends an Accept: image/webp header.

Serve Images from a CDN with Correct Cache Headers

# Nginx — cache-control for images
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

The immutable directive tells browsers and CDNs that the resource will never change at this URL — no conditional requests needed during the max-age window. Pair this with content-hashed filenames (hero.a3f2b1.webp) so cache busting happens via URL change, not max-age expiry.


Fix 4: Eliminate Element Render Delay

Render delay happens after the LCP resource loads but before it paints. Common causes:

Remove JavaScript That Blocks Rendering

Any <script> tag without defer or async in <head> blocks HTML parsing, which delays LCP element discovery:

<!-- Blocks rendering ──────────────────────── -->
<script src="/analytics.js"></script>

<!-- Deferred — runs after HTML parsed ─────── -->
<script src="/analytics.js" defer></script>

<!-- Async — runs as soon as downloaded ──────  -->
<script src="/analytics.js" async></script>

For third-party scripts (analytics, chat widgets, A/B testing tools), defer is almost always correct. async may cause issues if the script depends on a specific position in the render lifecycle.

Add Explicit Dimensions to Images

If an <img> has no width and height attributes, the browser cannot compute its layout position until the image downloads, which delays rendering:

<!-- BAD: No dimensions -->
<img src="/hero.webp" alt="Hero">

<!-- GOOD: Explicit dimensions -->
<img src="/hero.webp" alt="Hero" width="1200" height="600">

Alternatively, use the aspect-ratio CSS property on the container.

Use Server-Side Rendering for Dynamic Content

If the LCP element is rendered by JavaScript (common in decoupled Drupal or React frontends), the browser must download, parse, and execute JavaScript before the element appears. This adds 500ms-2s to LCP on mobile. Use server-side rendering (SSR) or static site generation (SSG) so the LCP element is in the initial HTML response.

For headless Drupal with Next.js, use getStaticProps or getServerSideProps to ensure the LCP element is rendered in the HTML sent to the browser.


Drupal-Specific LCP Improvements

BigPipe and LCP

Drupal's BigPipe module speeds up page rendering for authenticated users by streaming placeholders for slow components and filling them in asynchronously. However, if the LCP element is inside a BigPipe placeholder, it will not render until the subsequent request completes. Move the hero image or heading outside of any BigPipe-affected render element:

// Ensure the hero is not inside a lazy-builder or #lazy_builder render array
$build['hero'] = [
    '#theme'       => 'image',
    '#uri'         => $image_uri,
    '#attributes'  => ['fetchpriority' => 'high', 'width' => 1200, 'height' => 600],
    '#cache'       => ['contexts' => ['url']],
    // No #lazy_builder here
];

Preconnect to Third-Party Origins

If fonts or images are served from a third-party origin (Google Fonts, a CDN), add a preconnect hint to eliminate the DNS + TCP + TLS handshake time from the LCP critical path:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://images.example-cdn.com">

In Drupal, add these in a custom hook or via the html_head render array in a preprocess function.


Summary Checklist

  • Identify your actual LCP element with Chrome DevTools Performance panel before optimising.
  • Fix TTFB first — enable Drupal page caching, add Redis, consider Varnish or a CDN.
  • Place the LCP image in HTML, not as a CSS background, so the preload scanner discovers it immediately.
  • Add fetchpriority="high" to the LCP image. Never add loading="lazy" to it.
  • Use a <link rel="preload"> with fetchpriority="high" for LCP images served via CSS or as font faces.
  • Convert hero images to WebP/AVIF — target 30-50% size reduction vs JPEG.
  • Add width and height attributes to all images to prevent layout shifts that delay rendering.
  • Add defer to all non-critical scripts in <head>; remove any synchronous third-party scripts from the critical path.
  • Serve images with Cache-Control: public, immutable and a long max-age; use content-hashed filenames for cache busting.
  • Add rel="preconnect" hints for third-party origins used by LCP resources.
  • Ensure the LCP element is not inside a Drupal BigPipe placeholder or a client-rendered React component.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.