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
- CLS Optimization - Fix Cumulative Layout Shift issues
- Troubleshooting Overview - General Craft CMS troubleshooting
- Events Not Firing - Fix tracking issues