Fixing LCP Issues on Drupal
Overview
Largest Contentful Paint (LCP) measures how quickly the main content of a page loads. Good LCP (under 2.5 seconds) is crucial for user experience and SEO. This guide covers Drupal-specific optimizations for improving LCP scores.
Understanding LCP in Drupal
What Counts as LCP?
Common LCP elements on Drupal sites:
- Hero images (often from field_image on nodes)
- Banner regions
- Large content images
- Video thumbnails
- Paragraphs module content blocks
LCP Targets
- Good: < 2.5 seconds
- Needs Improvement: 2.5 - 4.0 seconds
- Poor: > 4.0 seconds
Diagnosing LCP Issues
Identify the LCP Element
Using Chrome DevTools:
- Open DevTools (F12)
- Go to Performance tab
- Click Record
- Reload page
- Stop recording
- Look for LCP marker in timeline
- Click to see which element
Using Web Vitals Extension:
Install Web Vitals Chrome extension
- View real-time LCP score
- See which element is LCP
- Get improvement suggestions
Common Causes in Drupal
- Unoptimized images from WYSIWYG
- Large hero images without responsive styles
- Slow server response (TTFB)
- Render-blocking CSS/JS
- Multiple cache layers causing delays
- BigPipe placeholder delays
Solution 1: Image Optimization
Configure Image Styles
Navigate to: /admin/config/media/image-styles
Create optimized styles:
# Recommended image styles
hero_desktop:
width: 1920
height: 1080
quality: 85
format: WebP (fallback: JPEG)
hero_tablet:
width: 1024
height: 768
quality: 85
format: WebP
hero_mobile:
width: 640
height: 480
quality: 80
format: WebP
content_large:
width: 1200
height: null (maintain aspect)
quality: 85
Install Image Optimization Modules
# WebP support
composer require drupal/imageapi_optimize drupal/imageapi_optimize_webp
drush en imageapi_optimize imageapi_optimize_webp -y
# Lazy loading (Drupal 9.2+)
composer require drupal/lazy
drush en lazy -y
# Responsive images (core module)
drush en responsive_image -y
Configure Responsive Images
Create responsive image style:
Navigate to: /admin/config/media/responsive-image-style/add
Name: Hero Responsive
Breakpoint group: Theme breakpoints
Breakpoints:
- Desktop (min-width: 1200px): hero_desktop
- Tablet (min-width: 768px): hero_tablet
- Mobile (max-width: 767px): hero_mobile
Fallback image style: hero_desktop
Update Field Display
Navigate to: /admin/structure/types/manage/[content-type]/display
For hero image field:
- Format: Responsive image
- Responsive image style: Hero Responsive
- ✅ Enable lazy loading (but NOT for LCP image!)
Preload LCP Image
File: themes/custom/mytheme/mytheme.theme
<?php
/**
* Implements hook_preprocess_node().
*/
function mytheme_preprocess_node(&$variables) {
$node = $variables['node'];
// Only for full view mode
if ($variables['view_mode'] !== 'full') {
return;
}
// Preload hero image (LCP optimization)
if ($node->hasField('field_hero_image') && !$node->get('field_hero_image')->isEmpty()) {
/** @var \Drupal\file\Entity\File $file */
$file = $node->get('field_hero_image')->entity;
if ($file) {
$image_style = \Drupal::entityTypeManager()
->getStorage('image_style')
->load('hero_desktop');
$image_url = $image_style->buildUrl($file->getFileUri());
// Add preload link
$variables['#attached']['html_head_link'][] = [
[
'rel' => 'preload',
'as' => 'image',
'href' => $image_url,
'imagesrcset' => _mytheme_generate_srcset($file),
'imagesizes' => '100vw',
],
TRUE
];
}
}
}
/**
* Generate srcset for responsive images.
*/
function _mytheme_generate_srcset($file) {
$styles = ['hero_mobile', 'hero_tablet', 'hero_desktop'];
$srcset = [];
foreach ($styles as $style_name) {
$image_style = \Drupal::entityTypeManager()
->getStorage('image_style')
->load($style_name);
if ($image_style) {
$url = $image_style->buildUrl($file->getFileUri());
$width = $image_style->getDerivativeExtension($file->getFileUri())['width'] ?? '';
$srcset[] = $url . ' ' . $width . 'w';
}
}
return implode(', ', $srcset);
}
Lazy Load Non-LCP Images
Twig template: templates/field/field--field-body.html.twig
{#
/**
* @file
* Theme override for body field with lazy loading.
*/
#}
{% for item in items %}
<div{{ item.attributes.addClass('field__item') }}>
{% if item.content['#type'] == 'processed_text' %}
{{ item.content|lazy_load_images }}
{% else %}
{{ item.content }}
{% endif %}
</div>
{% endfor %}
Create custom filter:
<?php
/**
* Implements hook_preprocess_field().
*/
function mytheme_preprocess_field(&$variables) {
// Don't lazy load first image (likely LCP)
if ($variables['field_name'] === 'field_body') {
// Add flag to skip first image
$variables['skip_first_lazy'] = TRUE;
}
}
Solution 2: Optimize Server Response Time (TTFB)
Enable Drupal Caching
File: settings.php
<?php
// Page cache for anonymous users
$config['system.performance']['cache']['page']['max_age'] = 3600; // 1 hour
// CSS/JS aggregation
$config['system.performance']['css']['preprocess'] = TRUE;
$config['system.performance']['js']['preprocess'] = TRUE;
// Enable render cache
$settings['cache']['bins']['render'] = 'cache.backend.database';
// Dynamic page cache (for authenticated users)
$settings['cache']['bins']['dynamic_page_cache'] = 'cache.backend.database';
Use Redis for Caching
# Install Redis module
composer require drupal/redis predis/predis
drush en redis -y
File: settings.php
<?php
// Redis configuration
if (extension_loaded('redis')) {
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host'] = 'localhost';
$settings['redis.connection']['port'] = 6379;
// Use Redis for all cache bins
$settings['cache']['default'] = 'cache.backend.redis';
// Keep important bins in database
$settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['config'] = 'cache.backend.chainedfast';
// Cache lifetime
$settings['cache_lifetime'] = 0;
$settings['cache_class_cache_page'] = 'Redis_Cache';
}
Enable Varnish (Advanced)
# Install Varnish purge module
composer require drupal/purge drupal/varnish_purge
drush en purge purge_ui varnish_purger varnish_purge_tags -y
Configure: /admin/config/development/performance/purge
Varnish VCL configuration:
vcl 4.0;
backend default {
.host = "127.0.0.1";
.port = "8080";
}
sub vcl_recv {
# Allow purging
if (req.method == "PURGE") {
if (!client.ip ~ purge) {
return(synth(405, "Not allowed."));
}
return (purge);
}
# Don't cache admin pages
if (req.url ~ "^/admin" || req.url ~ "^/user") {
return (pass);
}
# Remove all cookies except session cookies
if (req.http.Cookie) {
set req.http.Cookie = ";" + req.http.Cookie;
set req.http.Cookie = regsuball(req.http.Cookie, "; +", ";");
set req.http.Cookie = regsuball(req.http.Cookie, ";(SESS[a-z0-9]+|SSESS[a-z0-9]+|NO_CACHE)=", "; \1=");
set req.http.Cookie = regsuball(req.http.Cookie, ";[^ ][^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "^[; ]+|[; ]+$", "");
if (req.http.Cookie == "") {
unset req.http.Cookie;
}
else {
return (pass);
}
}
}
Database Optimization
# Optimize database tables
drush sql:query "OPTIMIZE TABLE cache_bootstrap, cache_config, cache_data, cache_default, cache_discovery, cache_dynamic_page_cache, cache_entity, cache_menu, cache_render, cache_page;"
# Enable query caching (my.cnf)
File: /etc/mysql/my.cnf
[mysqld]
query_cache_type = 1
query_cache_size = 64M
query_cache_limit = 2M
innodb_buffer_pool_size = 512M
Solution 3: Reduce Render-Blocking Resources
Optimize CSS Loading
File: themes/custom/mytheme/mytheme.libraries.yml
global-styling:
css:
theme:
css/critical.css: { preprocess: false, weight: -100 }
css/non-critical.css: { preprocess: true, weight: 0 }
Extract critical CSS:
Use tools like:
- Critical (Node.js)
- Penthouse
- Critical CSS module
Install Critical CSS module:
composer require drupal/critical_css
drush en critical_css -y
Configure: /admin/config/development/performance/critical-css
Defer Non-Critical CSS
File: templates/html.html.twig
<head>
{# Critical CSS inline #}
<style>
{{ critical_css|raw }}
</style>
{# Defer non-critical CSS #}
<link rel="preload" href="{{ base_path ~ directory }}/css/style.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ base_path ~ directory }}/css/style.css"></noscript>
{{ page.head }}
<css-placeholder token="{{ placeholder_token }}">
</head>
Optimize JavaScript Loading
Use defer/async:
<?php
/**
* Implements hook_js_alter().
*/
function mytheme_js_alter(&$javascript) {
foreach ($javascript as $key => $script) {
// Skip core libraries
if (strpos($key, 'core/') === 0) {
continue;
}
// Add defer to custom scripts
if (!isset($script['attributes'])) {
$javascript[$key]['attributes'] = [];
}
$javascript[$key]['attributes']['defer'] = TRUE;
}
}
Move scripts to footer:
// Move jQuery to footer
$javascript['core/jquery']['footer'] = TRUE;
Solution 4: CDN Integration
Setup Cloudflare or CDN
# Install CDN module
composer require drupal/cdn
drush en cdn -y
Configure: /admin/config/development/cdn
CDN domain: cdn.example.com
Mode: File conveyor
Or configure in settings.php:
<?php
$config['cdn.settings']['status'] = TRUE;
$config['cdn.settings']['mapping']['type'] = 'simple';
$config['cdn.settings']['mapping']['domain'] = 'cdn.example.com';
$config['cdn.settings']['mapping']['conditions'] = [
'extensions' => ['css', 'js', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg', 'woff', 'woff2'],
];
Cloudflare Specific Settings
Page Rules:
*.css - Cache Level: Cache Everything, Edge TTL: 1 month
*.js - Cache Level: Cache Everything, Edge TTL: 1 month
*.jpg|*.png|*.webp - Cache Level: Cache Everything, Edge TTL: 1 month
Performance Settings:
- ✅ Auto Minify: HTML, CSS, JS
- ✅ Brotli compression
- ✅ HTTP/2 to Origin
- ✅ Rocket Loader (test carefully)
Solution 5: Font Optimization
Preload Web Fonts
File: mytheme.theme
<?php
/**
* Implements hook_page_attachments().
*/
function mytheme_page_attachments(array &$attachments) {
// Preload critical fonts
$attachments['#attached']['html_head_link'][] = [
[
'rel' => 'preload',
'href' => '/themes/custom/mytheme/fonts/font.woff2',
'as' => 'font',
'type' => 'font/woff2',
'crossorigin' => 'anonymous',
],
TRUE
];
}
Use font-display: swap
File: css/typography.css
@font-face {
font-family: 'CustomFont';
src: url('../fonts/font.woff2') format('woff2'),
url('../fonts/font.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap; /* Prevent invisible text */
}
Self-Host Google Fonts
# Use google-webfonts-helper
# Download fonts from: https://google-webfonts-helper.herokuapp.com/
# Place in themes/custom/mytheme/fonts/
Solution 6: BigPipe Optimization
Exclude Pages from BigPipe
<?php
/**
* Implements hook_page_attachments().
*/
function mytheme_page_attachments(array &$attachments) {
$route_match = \Drupal::routeMatch();
// Disable BigPipe on landing pages for better LCP
if ($route_match->getRouteName() === 'entity.node.canonical') {
$node = $route_match->getParameter('node');
if ($node->bundle() === 'landing_page') {
// Remove BigPipe
$module_handler = \Drupal::service('module_handler');
if ($module_handler->moduleExists('big_pipe')) {
unset($attachments['#attached']['library'][array_search('big_pipe/big_pipe', $attachments['#attached']['library'])]);
}
}
}
}
Monitoring LCP
Real User Monitoring (RUM)
Install RUM Drupal module:
composer require drupal/rum_drupal
drush en rum_drupal -y
Or implement custom RUM:
// Send Web Vitals to analytics
import {getLCP} from 'web-vitals';
getLCP(function(metric) {
// Send to GA4
gtag('event', 'web_vitals', {
event_category: 'Web Vitals',
event_label: 'LCP',
value: Math.round(metric.value),
metric_id: metric.id,
metric_value: metric.value,
metric_delta: metric.delta,
non_interaction: true,
});
});
Testing & Validation
Before and After Comparison
Measure baseline:
lighthouse https://yoursite.com --only-categories=performanceImplement fixes
Re-measure:
lighthouse https://yoursite.com --only-categories=performance --output=html --output-path=./after-report.htmlCompare scores
Test Multiple Pages
# Create test script
for url in /node/1 /node/2 /products /about; do
lighthouse "https://yoursite.com$url" --only-categories=performance --output=json --output-path="./lcp-test-$url.json"
done
Quick Wins Checklist
- ✅ Enable image styles for all images
- ✅ Use WebP format with JPEG fallback
- ✅ Preload LCP image
- ✅ Enable CSS/JS aggregation
- ✅ Implement Redis caching
- ✅ Use font-display: swap
- ✅ Defer non-critical JavaScript
- ✅ Enable Drupal page cache
- ✅ Optimize database queries
- ✅ Use CDN for static assets