Fix LCP in Drupal
Drupal's LCP problem is almost always the hero. The theme ships a fixed image style, the hero image lives inside a block that renders after the shell, and Drupal's aggregated CSS blocks the critical path with 60-120 KB of theme code before the browser can paint anything. On a stock Drupal 10 install with an Olivero or custom theme, LCP routinely lands between 3.2 and 4.5s on 4G. The same site, with the Responsive Image module configured, fetchpriority=high on the hero, the hero moved into the page twig template, critical CSS extracted, and WebP derivatives in the pipeline, delivers LCP under 2.0s on 4G and under 1.5s from a warm CDN cache. This guide walks through the five moves that actually move the needle on Drupal LCP in production.
Expected results
Before
3.6s
LCP (Needs Improvement) -- Drupal 10 with default theme, single-image-style hero, hero rendered inside a block, aggregated theme CSS blocking the critical path, no WebP
After
1.8s
LCP (Good) -- Responsive Image with 4-step source-set, fetchpriority=high and preload on hero, hero inlined in page twig, critical CSS extracted and inlined (~14 KB), WebP derivatives
Step-by-step fix
Configure the Responsive Image module with an LCP-appropriate image style set
Drupal 10 ships Responsive Image module in core. The default configuration produces a single derivative per image field, which forces you to serve one size to every viewport. Configure a dedicated responsive image style group for the LCP hero and build a mobile-first source-set: 400w for phones, 800w for small tablets, 1200w for large tablets, and 1600w for desktops. Attach the picture-element formatter to the hero image field so the browser picks the smallest derivative that fits.
# /admin/config/media/responsive-image-style/hero
# YAML export (via drush config:export)
id: hero
label: 'Hero image (LCP)'
breakpoint_group: bartik
fallback_image_style: hero_1600
image_style_mappings:
# Mobile: up to 640px viewport -> 400w derivative
- breakpoint_id: 'bartik.mobile'
multiplier: '1x'
image_mapping_type: image_style
image_mapping: hero_400
# Tablet: 641-1024px -> 800w
- breakpoint_id: 'bartik.tablet'
multiplier: '1x'
image_mapping_type: image_style
image_mapping: hero_800
# Narrow desktop: 1025-1440px -> 1200w
- breakpoint_id: 'bartik.narrow'
multiplier: '1x'
image_mapping_type: image_style
image_mapping: hero_1200
# Wide desktop: 1441px+ -> 1600w
- breakpoint_id: 'bartik.wide'
multiplier: '1x'
image_mapping_type: image_style
image_mapping: hero_1600
# Then attach the formatter to the hero field:
# /admin/structure/types/manage/landing_page/display
# field_hero_image -> Format = 'Responsive image', Style = 'Hero image (LCP)'
Add fetchpriority=high and an early preload for the hero image
Even with a correctly sized image, Drupal loads it late because the theme's CSS and JS still block the preload scanner from seeing the hero. Use hook_preprocess_page or a page twig preprocess to inject fetchpriority=high on the hero img tag and emit a matching link rel=preload as=image in the head so the preload scanner can start the download before the CSS parses.
<?php
// themes/custom/mytheme/mytheme.theme
use Drupal\file\Entity\File;
use Drupal\image\Entity\ImageStyle;
use Drupal\node\NodeInterface;
/**
* Implements hook_preprocess_page().
*
* Sets fetchpriority=high on the hero img and preloads a matching src.
*/
function mytheme_preprocess_page(array &$variables) {
$node = $variables['node'] ?? NULL;
if (!$node instanceof NodeInterface) {
return;
}
if (!$node->hasField('field_hero_image') || $node->get('field_hero_image')->isEmpty()) {
return;
}
// Add a class the twig template can hook fetchpriority on.
$variables['hero_is_lcp'] = TRUE;
// Emit a matching link rel=preload for the widest source in the set.
$fid = $node->get('field_hero_image')->target_id;
$file = File::load($fid);
if (!$file) {
return;
}
$style = ImageStyle::load('hero_1600');
$url = $style->buildUrl($file->getFileUri());
$variables['#attached']['html_head_link'][] = [
[
'rel' => 'preload',
'as' => 'image',
'href' => $url,
'imagesrcset' => sprintf('%s 1600w', $url),
'fetchpriority' => 'high',
'type' => 'image/webp',
],
// No key -> always emit; Drupal will not deduplicate cross-request.
FALSE,
];
}
{# themes/custom/mytheme/templates/page--landing-page.html.twig #}
<main class="landing">
{% if hero_is_lcp %}
{# Render the hero inline instead of via a block so the preload scanner
sees it before CSS parses. #}
<figure class="hero" aria-label="{{ node.field_hero_image.alt }}">
{{ content.field_hero_image|merge({
'#item_attributes': {
'fetchpriority': 'high',
'loading': 'eager',
'decoding': 'async',
}
}) }}
</figure>
{% endif %}
{{ page.content }}
</main>
Inline the hero markup in the twig template instead of a lazy-loaded block
A hero placed inside a Drupal block gets rendered after the shell resolves. For blocks below the fold that is fine; for the LCP element it adds one render round trip. Move the hero out of the block system and into the page or node twig template directly. The above step already includes this pattern; the important thing is to remove any block placement that duplicates or races the inlined hero (checked in /admin/structure/block).
# List all blocks currently placed in the theme
$ drush config:list | grep '^block.block\.'
# Inspect a suspected duplicate hero block
$ drush config:get block.block.hero_landing status region
# Disable a duplicate block cleanly (does not delete config)
$ drush config:set block.block.hero_landing status false -y
# After template changes, rebuild caches so the new page twig ships
$ drush cache:rebuild
# Verify the hero renders only once and inline in the page HTML
$ curl -s https://example.com/ | grep -c 'class="hero"'
# expected: 1
Extract critical CSS for the above-the-fold hero and defer the rest
Drupal's default asset aggregation bundles theme CSS into two or three large render-blocking files. For LCP, only the CSS needed to paint the hero has to be render-blocking. Run a critical CSS extraction tool (Critters or Penthouse) as part of the build pipeline, inline the ~14 KB result in the head via a preprocess hook, and defer the rest with a rel=preload + swap onload pattern.
<?php
// themes/custom/mytheme/mytheme.theme
use Drupal\Core\Asset\AttachedAssets;
/**
* Implements hook_preprocess_html().
*
* Inlines critical.css (built at deploy time by Penthouse) and defers the
* full aggregated stylesheet with a rel=preload swap.
*/
function mytheme_preprocess_html(array &$variables) {
$critical_path = DRUPAL_ROOT . '/themes/custom/mytheme/dist/critical.css';
if (!file_exists($critical_path)) {
return;
}
$critical_css = file_get_contents($critical_path);
// Inline the critical CSS in the head (before any external stylesheet).
$variables['#attached']['html_head'][] = [
[
'#tag' => 'style',
'#value' => $critical_css,
'#attributes' => ['data-critical' => 'true'],
],
'mytheme_critical_css',
];
// Add a noscript fallback that always applies the full sheet.
$variables['#attached']['html_head'][] = [
[
'#tag' => 'noscript',
'#value' => '<link rel="stylesheet" href="/themes/custom/mytheme/dist/main.css">',
],
'mytheme_noscript_css',
];
}
// Build step (package.json):
// "build:critical": "penthouse --url=http://drupal.ddev.site \\
// --css=themes/custom/mytheme/dist/main.css \\
// --output=themes/custom/mytheme/dist/critical.css \\
// --width=1440 --height=900 --keepLargerMediaQueries"
Enable a WebP derivative pipeline and confirm output via curl
The Responsive Image module can generate WebP derivatives through the Image API WebP module or ImageAPI Optimize plus a WebP binding. Enable the pipeline, add type=image/webp entries to the source-set with a JPEG fallback, and verify with curl that modern browsers actually receive image/webp Content-Type responses. WebP typically shaves 25-35% off JPEG payload and 40-50% off PNG, which translates directly to LCP time on image-bound pages.
# 1) Install the WebP module and generate derivatives via ImageAPI Optimize
$ composer require drupal/imageapi_optimize_webp drupal/imageapi_optimize
$ drush pm:enable imageapi_optimize imageapi_optimize_webp -y
# 2) Configure /admin/config/media/imageapi-optimize-pipelines
# Create a 'webp' pipeline with:
# - Convert to WebP (quality 82)
# - Optimize with cwebp binary
# Attach the pipeline to your hero image styles (hero_400, hero_800, ...)
# 3) Rebuild image derivatives after config change
$ drush image:derive-flush hero_400 hero_800 hero_1200 hero_1600
$ drush cache:rebuild
# 4) Warm the homepage so derivatives generate on disk
$ curl -s -o /dev/null https://example.com/
# 5) Verify curl receives image/webp Content-Type for the hero
$ curl -sI -H 'Accept: image/webp,image/*,*/*;q=0.8' \
https://example.com/sites/default/files/styles/hero_800/public/hero.jpg.webp | \
grep -iE 'content-type|content-length'
# expected:
# content-type: image/webp
# content-length: ~45000 (was ~72000 as JPEG)
# 6) Measure LCP with the Chrome UX Report field data or a synthetic run
$ npx lighthouse https://example.com/ --only-categories=performance \\
--form-factor=mobile --throttling.cpuSlowdownMultiplier=4 \\
--output=json --output-path=./lh-drupal.json
$ jq '.audits["largest-contentful-paint"].numericValue' lh-drupal.json
# expected: ~1800 (ms) after the full stack lands
Quick checklist
- Responsive Image style group configured for the hero (400/800/1200/1600w mobile-first ladder)
-
Hero image renders with
fetchpriority=high,loading=eager, and a matching<link rel=preload> - Hero markup lives in the page or node twig template, not in a block that renders after the shell
- Critical CSS is inlined in the head (~14 KB); the full stylesheet is deferred
-
WebP derivatives are generated and served with the correct
Content-Typeheader - Lighthouse mobile LCP under 2.5s on the after-state build
Frequently asked questions
The most common causes are a hero image served as a single fixed image style (so mobile downloads a 1600w JPEG), the hero placed inside a Drupal block that renders after the page shell (adding a render round trip), and Drupal's default asset aggregation blocking the critical CSS path with 60-120 KB of theme CSS. Configuring the Responsive Image module with a proper source-set, moving the hero into the page twig template, and inlining critical CSS eliminates the majority of Drupal LCP issues.
You need Responsive Image module. Image styles alone produce a single derivative per image, which forces you to pick one size that works for every viewport (usually 1200w or larger) and ship it to everyone including mobile users on 3G. Responsive Image builds a source-set from multiple image styles and lets the browser pick the smallest one that fits, which is the single largest LCP win on image-heavy Drupal pages.
Google rates LCP as Good under 2.5s, Needs Improvement between 2.5s and 4.0s, and Poor above 4.0s. On a well-tuned Drupal 10 site with Responsive Image, fetchpriority=high, critical CSS extraction, and WebP derivatives, LCP typically lands between 1.6 and 2.2s on 4G. Behind a CDN with edge cache HITs, LCP under 1.5s is achievable on repeat visits. Authenticated LCP is usually slightly higher because BigPipe streams personalized fragments after the shell.
Twig cache affects server-side render time, which is a TTFB concern rather than an LCP concern once the HTML is on the wire. However, a warm twig cache reduces server latency, which shortens the total LCP wall-clock by 100-300 ms on complex pages. Enable Twig auto_reload=false in production and rebuild caches on deploy so the twig template stays compiled between requests.
Open the page in Chrome DevTools, switch to the Performance panel, and record a page load. The LCP marker in the timing summary points at a specific DOM node. If the LCP element is not the hero image you intended, the hero is either hidden by CSS, rendered too late, or beaten to render by a larger text block. Move the hero earlier in the DOM, remove any display:none or transform-based hidden states, and re-record until the LCP marker lands on the hero.
Yes for images below the fold, no for the LCP element. Drupal 10 image formatters set loading=lazy by default, which is correct for most images but must be overridden with loading=eager on the hero. Use a theme preprocess hook or a custom formatter to force eager loading and fetchpriority=high on the LCP image, and keep lazy loading everywhere else. Loading the hero lazily is a common Drupal LCP regression that costs 300-800 ms.
Related resources
Complete LCP Guide
LCP thresholds, sub-parts (TTFB, load delay, load duration, render delay), and cross-platform strategies.
FixFix TTFB in Drupal
Companion Drupal fix: Internal Page Cache, Redis, opcache tuning, and reverse-proxy edge caching.
FixFix LCP in WordPress
Same LCP recipe on a different CMS: hero preload, above-the-fold plugins, WebP pipeline.
Continue learning
Complete LCP Guide
LCP thresholds, sub-parts, and cross-platform strategies.
FixFix LCP in Next.js
next/image priority, App Router streaming, hero preload.
FixCritical CSS Extraction
Extract, inline, and defer -- the recipe every framework uses.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.