Largest Contentful Paint (LCP) measures how quickly the main content of your Grav site loads. Optimize your flat-file CMS for better page load performance.
Target: LCP under 2.5 seconds Good: Under 2.5s | Needs Improvement: 2.5-4.0s | Poor: Over 4.0s
For general LCP concepts, see the global LCP guide.
Grav-Specific LCP Issues
1. Slow PHP Processing
Grav is a flat-file CMS that processes Twig templates and parses markdown on every request.
Problem: Slow server-side processing delays TTFB (Time to First Byte).
Diagnosis:
- Check server response time in browser DevTools (Network tab → TTFB)
- Target: TTFB under 200ms
Solutions:
A. Enable Grav Caching
# user/config/system.yaml
cache:
enabled: true
check:
method: file
driver: auto
prefix: 'g'
purge_at: '0 4 * * *' # Purge cache daily at 4am
clear_at: '0 3 * * *' # Clear cache daily at 3am
clear_images_by_default: true
cli_compatibility: false
lifetime: 604800 # 7 days
gzip: false
allow_webserver_gzip: false
redis:
socket: false
B. Enable Twig Caching
# user/config/system.yaml
twig:
cache: true
debug: false
auto_reload: false
autoescape: false
C. Enable Asset Pipeline
# user/config/system.yaml
assets:
css_pipeline: true
css_pipeline_include_externals: true
css_pipeline_before_excludes: true
css_minify: true
css_minify_windows: false
css_rewrite: true
js_pipeline: true
js_pipeline_include_externals: true
js_pipeline_before_excludes: true
js_minify: true
enable_asset_timestamp: false
collections:
jquery: system://assets/jquery/jquery-2.x.min.js
D. Use PHP OPcache
# php.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.revalidate_freq=2
opcache.fast_shutdown=1
E. Optimize PHP-FPM
# php-fpm pool configuration
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
2. Unoptimized Images
Grav's media library doesn't automatically optimize images.
Problem: Large images as LCP element.
Diagnosis:
- Run PageSpeed Insights
- Look for "Properly size images" warning
- Check if images are in modern formats (WebP, AVIF)
Solutions:
A. Use Grav Image Processing
{# Resize and optimize images in Twig templates #}
{# Original large image #}
<img src="{{ page.media['hero.jpg'].url }}" alt="Hero">
{# Optimized with Grav media processing #}
<img src="{{ page.media['hero.jpg'].resize(1200, 600).quality(85).url }}"
alt="Hero"
width="1200"
height="600">
B. Responsive Images
{# Generate responsive srcset #}
{% set image = page.media['featured.jpg'] %}
<img src="{{ image.resize(800, 600).url }}"
srcset="{{ image.resize(400, 300).url }} 400w,
{{ image.resize(800, 600).url }} 800w,
{{ image.resize(1200, 900).url }} 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="{{ page.title }}"
loading="lazy">
C. WebP Conversion
{# Convert to WebP format #}
<picture>
<source srcset="{{ page.media['hero.jpg'].resize(1200, 600).format('webp').url }}" type="image/webp">
<img src="{{ page.media['hero.jpg'].resize(1200, 600).url }}"
alt="Hero"
width="1200"
height="600">
</picture>
D. Preload LCP Image
{# In templates/partials/head.html.twig #}
{% if page.header.featured_image %}
{% set featured = page.media[page.header.featured_image] %}
<link rel="preload"
as="image"
href="{{ featured.resize(1200, 600).url }}"
imagesrcset="{{ featured.resize(400, 300).url }} 400w,
{{ featured.resize(800, 600).url }} 800w,
{{ featured.resize(1200, 900).url }} 1200w"
imagesizes="100vw">
{% endif %}
E. Image Optimization Plugin
Install and configure the Image Optimization plugin:
bin/gpm install image-captions
Or use external services like Cloudinary:
{# Upload images to Cloudinary and optimize #}
<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_1200/sample.jpg"
alt="Optimized Image">
3. Render-Blocking Resources
CSS and JavaScript can delay LCP.
Problem: Render-blocking stylesheets and scripts.
Solutions:
A. Inline Critical CSS
{# templates/partials/head.html.twig #}
<style>
/* Critical above-the-fold CSS */
body { margin: 0; font-family: sans-serif; }
.header { background: #333; color: white; padding: 1rem; }
.hero { min-height: 400px; }
</style>
{# Load full CSS asynchronously #}
<link rel="preload" href="{{ theme_url }}/css/style.css" as="style"
<noscript><link rel="stylesheet" href="{{ theme_url }}/css/style.css"></noscript>
B. Defer Non-Critical JavaScript
{# Defer JavaScript #}
{{ assets.js('defer')|raw }}
{# Or manually #}
<script src="{{ theme_url }}/js/main.js" defer></script>
C. Remove Unused CSS/JS
Check what assets are actually used:
# Disable unused Grav plugins
plugins:
breadcrumbs:
enabled: false # If not using
related-pages:
enabled: false # If not using
4. Slow Server Response (TTFB)
Problem: Server takes too long to generate page.
Solutions:
A. Enable Static Page Caching
Use a caching plugin or reverse proxy:
# Install Cache plugin
bin/gpm install cache
# user/config/plugins/cache.yaml
enabled: true
cache_control: public, max-age=3600
B. Use CDN
Configure CDN for static assets:
{# Use CDN for assets #}
{% set cdn_url = 'https://cdn.example.com' %}
<link rel="stylesheet" href="{{ cdn_url }}{{ theme_url }}/css/style.css">
<script src="{{ cdn_url }}{{ theme_url }}/js/main.js"></script>
C. Optimize Database (if using DB plugins)
Some plugins use databases. Optimize if applicable:
-- Optimize database tables
OPTIMIZE TABLE grav_cache;
OPTIMIZE TABLE grav_sessions;
5. Heavy Twig Template Processing
Problem: Complex Twig logic slows page generation.
Solutions:
A. Cache Twig Partials
{# Cache expensive operations #}
{% cache 3600 %}
{# Expensive query or operation #}
{% set posts = page.collection({'items':{'@taxonomy':{'category':'news'}}}) %}
{% for post in posts.slice(0, 5) %}
{{ post.title }}
{% endfor %}
{% endcache %}
B. Reduce Collection Queries
{# Bad - Multiple queries #}
{% for category in ['news', 'blog', 'articles'] %}
{% set posts = page.find('/blog').collection({'items':{'@taxonomy':{'category': category}}}) %}
{% endfor %}
{# Good - Single query #}
{% set allPosts = page.find('/blog').collection() %}
{% for category in ['news', 'blog', 'articles'] %}
{% set posts = allPosts|filter(p => p.taxonomy.category.first == category) %}
{% endfor %}
C. Limit Collection Size
{# Limit items processed #}
{% set posts = page.collection({'items':{'@page':{'limit':10}}}) %}
6. Third-Party Scripts
Problem: External scripts delay LCP.
Solutions:
A. Defer Analytics
{# Load GA4/GTM after page load #}
<script>
window.addEventListener('load', function() {
// Load GTM or GA4 here
(function(w,d,s,l,i){ /* GTM code */ })();
});
</script>
B. Use Facade Pattern for Heavy Widgets
<!-- Lazy load YouTube embeds -->
<div class="youtube-facade" data-video-id="dQw4w9WgXcQ">
<img src="https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg" alt="Video">
<button class="play-button">Play</button>
</div>
<script>
document.querySelectorAll('.youtube-facade').forEach(function(el) {
el.addEventListener('click', function() {
var iframe = document.createElement('iframe');
iframe.src = 'https://www.youtube.com/embed/' + el.dataset.videoId + '?autoplay=1';
el.replaceWith(iframe);
});
});
</script>
7. Font Loading
Problem: Custom fonts block text rendering.
Solutions:
A. Use font-display: swap
/* user/themes/your-theme/css/custom.css */
@font-face {
font-family: 'CustomFont';
src: url('../fonts/custom.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
font-weight: 400;
font-style: normal;
}
B. Preload Fonts
<link rel="preload" href="{{ theme_url }}/fonts/custom.woff2" as="font" type="font/woff2" crossorigin>
C. Self-Host Fonts
Don't use Google Fonts CDN - self-host instead:
# Download fonts
mkdir -p user/themes/your-theme/fonts
# Copy .woff2 files to fonts directory
Testing & Monitoring
Test LCP
Tools:
- PageSpeed Insights
- WebPageTest
- Chrome DevTools Lighthouse
Test Different Pages:
- Homepage
- Blog posts
- Modular pages
- Collection pages
Monitor with RUM
{# Real User Monitoring #}
<script>
new PerformanceObserver(function(list) {
var entries = list.getEntries();
entries.forEach(function(entry) {
if (entry.entryType === 'largest-contentful-paint') {
// Send to analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'lcp', {
'value': Math.round(entry.renderTime || entry.loadTime),
'page_type': '{{ page.template() }}'
});
}
}
});
}).observe({entryTypes: ['largest-contentful-paint']});
</script>
Quick Wins Checklist
- Enable Grav cache (system.yaml)
- Enable Twig cache
- Enable asset pipeline (CSS/JS minification)
- Enable PHP OPcache
- Resize images with Grav media processing
- Convert images to WebP
- Preload LCP image
- Inline critical CSS
- Defer non-critical JavaScript
- Remove unused plugins
- Use font-display: swap
- Implement CDN for static assets
When to Hire a Developer
Consider professional help if:
- LCP consistently over 4 seconds after optimizations
- Complex custom theme requires refactoring
- Need custom caching layer implementation
- Server infrastructure needs optimization
- Multiple plugins causing conflicts
Next Steps
For general LCP optimization strategies, see LCP Optimization Guide.