Fix LCP Issues on Grav (Loading Speed) | OpsBlu Docs

Fix LCP Issues on Grav (Loading Speed)

Speed up Grav LCP by enabling flat-file page caching, optimizing Twig template rendering, and compressing media manager images.

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:

  1. PageSpeed Insights
  2. WebPageTest
  3. 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.