Fixing LCP Issues on Drupal | Blue Frog Docs

Fixing LCP Issues on Drupal

Comprehensive guide to diagnosing and fixing Largest Contentful Paint issues on Drupal sites

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:

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Click Record
  4. Reload page
  5. Stop recording
  6. Look for LCP marker in timeline
  7. 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

  1. Unoptimized images from WYSIWYG
  2. Large hero images without responsive styles
  3. Slow server response (TTFB)
  4. Render-blocking CSS/JS
  5. Multiple cache layers causing delays
  6. 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:

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:


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

  1. Measure baseline:

    lighthouse https://yoursite.com --only-categories=performance
    
  2. Implement fixes

  3. Re-measure:

    lighthouse https://yoursite.com --only-categories=performance --output=html --output-path=./after-report.html
    
  4. Compare 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

Resources


Next Steps

// SYS.FOOTER