Fix Craft CMS LCP Issues | OpsBlu Docs

Fix Craft CMS LCP Issues

Speed up Craft CMS LCP with native image transforms, Twig template optimization, Redis caching, and asset preloading strategies.

Learn how to identify and fix Largest Contentful Paint (LCP) issues in Craft CMS using image transforms, asset optimization, caching strategies, and performance best practices.

Understanding LCP in Craft CMS

LCP measures the render time of the largest visible content element. In Craft CMS sites, this is commonly:

  • Hero images from asset fields
  • Featured images in blog posts
  • Product images in e-commerce
  • Matrix block images in content builders

Target: LCP should occur within 2.5 seconds for good performance.

Diagnosing LCP Issues

Step 1: Identify the LCP Element

Use Chrome DevTools or Lighthouse:

// Run in browser console
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP element:', lastEntry.element);
  console.log('LCP time:', lastEntry.renderTime || lastEntry.loadTime);
}).observe({type: 'largest-contentful-paint', buffered: true});

Step 2: Check Current LCP Score

# Using Lighthouse CLI
npm install -g lighthouse
lighthouse https://yoursite.com --only-categories=performance --view

Image Optimization

Use Craft's Native Image Transforms

The most common LCP issue is serving oversized images:

{# BAD - Serving full-size image #}
{% set heroImage = entry.heroImage.one() %}
<img src="{{ heroImage.url }}" alt="{{ heroImage.title }}">

{# GOOD - Using optimized transform #}
{% set heroImage = entry.heroImage.one() %}
{% set transform = {
  width: 1920,
  height: 1080,
  mode: 'crop',
  quality: 80,
  format: 'webp',
  position: 'center-center'
} %}
<img src="{{ heroImage.getUrl(transform) }}"
     alt="{{ heroImage.title }}"
     width="1920"
     height="1080">

Implement Responsive Images

Serve appropriate sizes for different devices:

{% set heroImage = entry.heroImage.one() %}

{# Define transforms for different screen sizes #}
{% set transforms = {
  mobile: { width: 640, height: 480, quality: 80, format: 'webp' },
  tablet: { width: 1024, height: 768, quality: 80, format: 'webp' },
  desktop: { width: 1920, height: 1080, quality: 80, format: 'webp' },
  desktopLarge: { width: 2560, height: 1440, quality: 75, format: 'webp' }
} %}

<picture>
  {# WebP sources #}
  <source media="(min-width: 1920px)"
          srcset="{{ heroImage.getUrl(transforms.desktopLarge) }}">
  <source media="(min-width: 1024px)"
          srcset="{{ heroImage.getUrl(transforms.desktop) }}">
  <source media="(min-width: 768px)"
          srcset="{{ heroImage.getUrl(transforms.tablet) }}">

  {# Fallback #}
  <img src="{{ heroImage.getUrl(transforms.mobile) }}"
       alt="{{ heroImage.title }}"
       width="1920"
       height="1080"
       loading="eager">
</picture>

WebP with Fallback

Serve modern formats with fallbacks:

{% set heroImage = entry.heroImage.one() %}

<picture>
  {# WebP version #}
  <source srcset="{{ heroImage.getUrl({ width: 1920, format: 'webp', quality: 80 }) }}"
          type="image/webp">

  {# JPEG fallback #}
  <img src="{{ heroImage.getUrl({ width: 1920, quality: 85 }) }}"
       alt="{{ heroImage.title }}"
       width="1920"
       height="1080">
</picture>

Preloading Critical Assets

Preload LCP Image

Add preload hints for hero images:

{# templates/_layouts/base.twig #}
{% set heroImage = entry.heroImage.one() ?? null %}

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">

    {# Preload the LCP image #}
    {% if heroImage %}
      {% set heroTransform = { width: 1920, format: 'webp', quality: 80 } %}
      <link rel="preload"
            as="image"
            href="{{ heroImage.getUrl(heroTransform) }}"
            imagesrcset="{{ heroImage.getUrl({ width: 640, format: 'webp' }) }} 640w,
                        {{ heroImage.getUrl({ width: 1024, format: 'webp' }) }} 1024w,
                        {{ heroImage.getUrl({ width: 1920, format: 'webp' }) }} 1920w"
            imagesizes="100vw">
    {% endif %}

    {# Other head content #}
</head>
<body>
    {# Content #}
</body>
</html>

Conditional Preloading

Only preload on pages where it matters:

{# Only preload hero image on homepage and landing pages #}
{% if entry.section.handle in ['homepage', 'landingPages'] %}
  {% set heroImage = entry.heroImage.one() %}
  {% if heroImage %}
    <link rel="preload"
          as="image"
          href="{{ heroImage.getUrl({ width: 1920, format: 'webp' }) }}">
  {% endif %}
{% endif %}

Asset Transform Configuration

Optimize Transform Settings

Configure transforms in config/general.php:

// config/general.php
return [
    '*' => [
        'transformGifs' => false, // Don't transform GIFs
        'optimizeImageFilesize' => true,
        'imageDriver' => 'imagick', // Use ImageMagick if available
        'defaultImageQuality' => 80,
    ],
];

Use Imager-X Plugin

For advanced transforms and optimization:

composer require spacecatninja/imager-x
{# Using Imager-X for optimized transforms #}
{% set heroImage = entry.heroImage.one() %}
{% set transformedImages = craft.imagerx.transformImage(heroImage, [
  { width: 640 },
  { width: 1024 },
  { width: 1920 }
], {
  format: 'webp',
  quality: 80,
  optimizeType: 'webp'
}) %}

<img src="{{ transformedImages[2] }}"
     srcset="{{ transformedImages[0] }} 640w,
             {{ transformedImages[1] }} 1024w,
             {{ transformedImages[2] }} 1920w"
     sizes="100vw"
     alt="{{ heroImage.title }}">

Caching Strategies

Enable Template Caching

Cache expensive template operations:

{# Cache hero section globally #}
{% cache globally using key "hero-section-#{entry.id}" for 1 day %}
  {% set heroImage = entry.heroImage.one() %}
  {% if heroImage %}
    <section class="hero">
      <img src="{{ heroImage.getUrl({ width: 1920, format: 'webp' }) }}"
           alt="{{ heroImage.title }}">
    </section>
  {% endif %}
{% endcache %}

Cache Image Transforms

Ensure transforms are cached:

// config/general.php
return [
    'production' => [
        'generateTransformsBeforePageLoad' => true,
        'transformsCacheDuration' => 'P1Y', // 1 year
    ],
];

Eager Generate Transforms

Pre-generate transforms on asset upload:

// config/imager-x.php (if using Imager-X)
return [
    'generateTransformsBeforePageLoad' => true,
    'preGenerateTransforms' => [
        ['width' => 640, 'format' => 'webp'],
        ['width' => 1024, 'format' => 'webp'],
        ['width' => 1920, 'format' => 'webp'],
    ],
];

CDN Integration

Using Craft's Asset Platform

Configure CDN for assets:

// config/general.php
return [
    'production' => [
        'transformsCdnUrl' => 'https://cdn.yoursite.com/',
    ],
];

Amazon S3 / CloudFront

composer require craftcms/aws-s3
// Configure in Control Panel:
// Settings → Assets → Volumes → Create S3 Volume

// Or in config/volumes.php
return [
    's3Volume' => [
        'hasUrls' => true,
        'url' => 'https://d1234567890.cloudfront.net/',
        'bucket' => 'your-bucket-name',
        'region' => 'us-east-1',
        'subfolder' => 'assets',
    ],
];

Critical CSS

Inline Critical CSS

Extract and inline above-the-fold CSS:

{# templates/_layouts/base.twig #}
<head>
    {# Inline critical CSS #}
    <style>
        {# Hero section critical styles #}
        .hero {
            position: relative;
            width: 100%;
            min-height: 80vh;
        }
        .hero img {
            width: 100%;
            height: auto;
            display: block;
        }
    </style>

    {# Load full CSS asynchronously #}
    <link rel="preload" href="/dist/css/main.css" as="style"
    <noscript><link rel="stylesheet" href="/dist/css/main.css"></noscript>
</head>

Using Critical CSS Plugin

npm install critical --save-dev

Generate critical CSS during build:

// build script
const critical = require('critical');

critical.generate({
  base: 'web/',
  src: 'index.html',
  target: {
    css: 'critical.css',
    html: 'index-critical.html'
  },
  width: 1920,
  height: 1080
});

Font Optimization

Preload Critical Fonts

Fonts can delay LCP if not optimized:

<head>
    {# Preload critical fonts #}
    <link rel="preload"
          href="/fonts/inter-var.woff2"
          as="font"
          type="font/woff2"
          crossorigin>

    {# Use font-display swap #}
    <style>
        @font-face {
            font-family: 'Inter';
            src: url('/fonts/inter-var.woff2') format('woff2');
            font-weight: 100 900;
            font-display: swap; /* Prevents invisible text */
        }
    </style>
</head>

Lazy Loading (Below the Fold)

Lazy Load Non-Critical Images

{# Hero image - eager loading #}
{% set heroImage = entry.heroImage.one() %}
<img src="{{ heroImage.getUrl({ width: 1920 }) }}"
     alt="{{ heroImage.title }}"
     loading="eager"
     fetchpriority="high">

{# Gallery images - lazy loading #}
{% for image in entry.gallery.all() %}
  <img src="{{ image.getUrl({ width: 800 }) }}"
       alt="{{ image.title }}"
       loading="lazy"
       width="800"
       height="600">
{% endfor %}

Native Lazy Loading with Fallback

{# Progressive enhancement approach #}
<img src="{{ image.getUrl({ width: 800 }) }}"
     alt="{{ image.title }}"
     loading="lazy"
     class="lazyload"
     data-src="{{ image.getUrl({ width: 800 }) }}"
     width="800"
     height="600">

<noscript>
  <img src="{{ image.getUrl({ width: 800 }) }}" alt="{{ image.title }}">
</noscript>

Matrix Field Optimization

Optimize Matrix Block Images

{% for block in entry.contentBlocks.all() %}
  {% switch block.type.handle %}

    {% case 'heroBlock' %}
      {# Hero block - eager load #}
      {% set image = block.heroImage.one() %}
      <img src="{{ image.getUrl({ width: 1920, format: 'webp' }) }}"
           alt="{{ image.title }}"
           loading="eager"
           fetchpriority="high">

    {% case 'imageBlock' %}
      {# Regular images - lazy load #}
      {% set image = block.image.one() %}
      <img src="{{ image.getUrl({ width: 800, format: 'webp' }) }}"
           alt="{{ image.title }}"
           loading="lazy">

  {% endswitch %}
{% endfor %}

Server-Side Optimizations

Enable Compression

# .htaccess
<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json
</IfModule>

HTTP/2 Push

# .htaccess
<IfModule mod_http2.c>
    H2PushResource add css/critical.css
    H2PushResource add fonts/main-font.woff2
</IfModule>

Browser Caching

# .htaccess
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType image/webp "access plus 1 year"
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
</IfModule>

Monitoring LCP

Real User Monitoring (RUM)

{# Track LCP for analytics #}
<script>
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];

    // Send to analytics
    gtag('event', 'web_vitals', {
      event_category: 'Web Vitals',
      event_label: 'LCP',
      value: Math.round(lastEntry.renderTime || lastEntry.loadTime),
      metric_id: 'lcp',
      metric_value: lastEntry.renderTime || lastEntry.loadTime,
      metric_delta: lastEntry.renderTime || lastEntry.loadTime
    });
  }).observe({type: 'largest-contentful-paint', buffered: true});
</script>

Field Instrumentation

{% if craft.app.config.general.devMode %}
<script>
  // Monitor LCP in development
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];

    console.group('LCP Metrics');
    console.log('LCP Element:', lastEntry.element);
    console.log('LCP Time:', lastEntry.renderTime || lastEntry.loadTime, 'ms');
    console.log('LCP Size:', lastEntry.size);
    console.log('LCP URL:', lastEntry.url);
    console.groupEnd();
  }).observe({type: 'largest-contentful-paint', buffered: true});
</script>
{% endif %}

Quick Wins Checklist

  • Use image transforms instead of full-size images
  • Implement WebP format with fallbacks
  • Add width/height attributes to images
  • Preload LCP image on critical pages
  • Use loading="eager" on above-fold images
  • Enable template caching for hero sections
  • Configure CDN for asset delivery
  • Inline critical CSS
  • Preload critical fonts with font-display: swap
  • Enable browser caching for assets
  • Use HTTP/2 if available
  • Lazy load below-fold images

Next Steps

Resources