Back to Blog
Craft CMS Performance Caching

Performance Tuning Craft CMS: Eager Loading, Caching, and Query Optimization

· 15 min read

Craft CMS is fast out of the box. But "fast out of the box" and "fast with 500 entries and a complex content model" are two different things. I've worked on Craft sites that loaded in under 200ms and sites that took 4 seconds. The difference usually comes down to a handful of mistakes that are easy to make and easy to fix once you know what to look for.

This post covers the performance patterns I use on every project. We'll start with the most impactful fix (eager loading) and work through caching, query optimization, and the tools I use to find bottlenecks.

The N+1 Problem (And Why Eager Loading Fixes It)

This is the single biggest performance issue I see on Craft sites. If you only take one thing away from this post, let it be this.

Here's a typical blog listing template:

templates/blog/index.twig (slow version)
{# This looks innocent but it's slow #}
{% set posts = craft.entries.section('blog').limit(20).all() %}

{% for post in posts %}
  <article>
    <h2><a href="{{ post.url }}">{{ post.title }}</a></h2>

    {% set image = post.featuredImage.one() %}
    {% if image %}
      <img src="{{ image.url }}" alt="{{ image.alt }}">
    {% endif %}

    {% set author = post.authorEntry.one() %}
    {% if author %}
      <span>By {{ author.title }}</span>
    {% endif %}

    {% set categories = post.blogCategories.all() %}
    {% for cat in categories %}
      <span>{{ cat.title }}</span>
    {% endfor %}
  </article>
{% endfor %}

This template generates a lot more database queries than you'd expect. Here's what actually happens:

N+1 Problem: 20 posts = 61 queries 1 query: Fetch 20 posts For each of the 20 posts... 20 queries: featuredImage 20 queries: authorEntry 20 queries: blogCategories 1 + 20 + 20 + 20 = 61 database queries With eager loading: 4 queries total (1 per relation type)

Every time the template accesses a relational field (featuredImage, authorEntry, blogCategories) inside the loop, Craft runs a separate database query. With 20 posts and 3 relational fields, that's 1 + (20 x 3) = 61 queries. With 50 posts, it's 151 queries. This adds up fast.

The fix is eager loading. You tell Craft upfront which relations you need, and it fetches them all in a handful of batch queries instead of one per element:

templates/blog/index.twig (fast version)
{# Eager load all the relations we need #}
{% set posts = craft.entries
  .section('blog')
  .limit(20)
  .with([
    'featuredImage',
    'authorEntry',
    'blogCategories',
  ])
  .all() %}

{% for post in posts %}
  <article>
    <h2><a href="{{ post.url }}">{{ post.title }}</a></h2>

    {# Use .eagerly() or access the pre-loaded data #}
    {% set image = post.featuredImage|first %}
    {% if image %}
      <img src="{{ image.url }}" alt="{{ image.alt }}">
    {% endif %}

    {% set author = post.authorEntry|first %}
    {% if author %}
      <span>By {{ author.title }}</span>
    {% endif %}

    {% for cat in post.blogCategories.all() %}
      <span>{{ cat.title }}</span>
    {% endfor %}
  </article>
{% endfor %}

That .with() call reduces 61 queries down to about 4. One for the posts, one for images, one for authors, one for categories. The performance difference is dramatic, especially on listing pages.

Eager Loading Nested Relations

If your Matrix blocks have their own relational fields, you can eager load those too using dot notation:

Nested eager loading
{% set entries = craft.entries
  .section('blog')
  .with([
    'featuredImage',
    'contentBlocks',
    'contentBlocks.image',
    'contentBlocks.relatedEntries',
    'contentBlocks.relatedEntries.featuredImage',
  ])
  .all() %}

Each dot level goes one relation deeper. This is especially important for content builder patterns where Matrix entries have their own asset fields and entry relations.

Eager Loading with Transforms

You can also pre-generate image transforms during eager loading, which avoids the transform generation hitting your server on the first page load:

Eager load with transforms
{% set entries = craft.entries
  .section('blog')
  .with([
    ['featuredImage', {
      withTransforms: [
        { width: 800, height: 450, mode: 'crop' },
        { width: 400, height: 225, mode: 'crop' },
      ]
    }],
  ])
  .all() %}
When in doubt, eager load everything. The only case where eager loading can hurt is if you load data you never actually use. But in practice, the cost of an unused batch query is almost nothing compared to the cost of N+1 lazy queries.

Template Caching

Craft has a built-in {% cache %} tag that stores rendered HTML output and serves it on subsequent requests without re-running the queries. It's incredibly effective for content that doesn't change often.

Template caching basics
{# Cache this block for 1 hour #}
{% cache for 1 hour %}
  {% set posts = craft.entries.section('blog').limit(10).all() %}
  {% for post in posts %}
    <article>
      <h2>{{ post.title }}</h2>
      <p>{{ post.body|truncate(200) }}</p>
    </article>
  {% endfor %}
{% endcache %}

The first request runs the queries and renders the HTML. Every request after that serves the cached HTML directly. No queries, no Twig rendering. The cache automatically busts when any of the entries referenced inside the cache block are updated.

Cache Wisely, Not Everywhere

I see people either caching nothing or caching everything. Both are wrong. Here's how I think about it:

  • Cache listing pages. Blog indexes, category pages, archive pages. These query a lot of entries but don't change on every request.
  • Cache expensive sidebar/footer widgets. A "recent posts" or "popular articles" widget that appears on every page is a great cache candidate.
  • Don't cache things with user-specific content. If the template shows different content based on the logged-in user, caching will serve the wrong content to the wrong people.
  • Don't cache forms. CSRF tokens need to be unique per session.
Cache with a unique key per page
{# Cache the sidebar per-page so related content is accurate #}
{% cache using key "sidebar-" ~ entry.id %}
  {% set related = craft.entries
    .section('blog')
    .relatedTo(entry)
    .limit(5)
    .all() %}
  <aside>
    <h3>Related Posts</h3>
    {% for post in related %}
      <a href="{{ post.url }}">{{ post.title }}</a>
    {% endfor %}
  </aside>
{% endcache %}
Craft's template cache is smart about invalidation. When you save an entry that was referenced inside a cache block, Craft automatically clears that specific cache. You don't need to manually bust caches after content updates in most cases.

Static Caching (The Nuclear Option)

For sites that need to be extremely fast and don't have dynamic per-request content, static caching is an option. This means caching the entire HTML response so neither Craft nor PHP needs to run at all for cached pages.

The most common approach is using the Blitz plugin by PutYourLightsOn. It generates static HTML files and serves them directly through Nginx or Apache, bypassing PHP entirely.

Terminal
composer require putyourlightson/craft-blitz
php craft plugin/install blitz

After installing, you configure which URL patterns should be statically cached in the plugin settings. Blitz handles cache warming (pre-generating all pages) and cache invalidation (clearing pages when content changes).

The performance numbers with Blitz are insane. We're talking single-digit millisecond response times because the web server is just serving a static HTML file from disk. But it comes with tradeoffs:

  • Dynamic content per user (login states, personalization) needs special handling
  • Form CSRF tokens need to be injected via JavaScript
  • Cache warming can be slow on large sites (thousands of pages)
  • More configuration and testing required

I use Blitz on sites that are mostly read-only with infrequent content updates. Marketing sites, documentation sites, and portfolio sites are great candidates. E-commerce sites or sites with logged-in user features usually aren't worth the complexity.

Query Optimization Tips

Beyond eager loading and caching, here are specific query patterns I've learned to watch for:

Use .select() to Limit Columns

If you only need titles and URLs, don't fetch every field on the entry:

Optimized queries
{# Only select what you need for a simple link list #}
{% set posts = craft.entries
  .section('blog')
  .select(['elements.id', 'content.title', 'elements_sites.slug'])
  .limit(10)
  .all() %}
Be careful with .select(). It returns raw data, not fully populated entry models. Field values and relational methods won't work on the results. Only use it when you truly just need a few specific columns, like building a sitemap or a simple nav list.

Avoid .all() When You Only Need One

This seems obvious but I see it constantly:

Use .one() for single entries
{# Bad: fetches all matching entries, then takes the first #}
{% set homepage = craft.entries.section('homepage').all()|first %}

{# Good: tells the database to only return one row #}
{% set homepage = craft.entries.section('homepage').one() %}

Use .exists() Instead of .count() for Boolean Checks

Efficient existence checks
{# Bad: counts every matching row just to check if any exist #}
{% if craft.entries.section('news').count() > 0 %}

{# Good: stops at the first match #}
{% if craft.entries.section('news').exists() %}

Limit and Offset vs. Pagination

If you're building a paginated listing, use Craft's built-in pagination rather than manually doing offset math:

Proper pagination
{% set postsQuery = craft.entries.section('blog') %}
{% set pageInfo = sprig.paginate(postsQuery, 10) %}

{# OR with Craft's built-in pagination #}
{% paginate craft.entries.section('blog').limit(10) as pageInfo, posts %}

{% for post in posts %}
  <article>{{ post.title }}</article>
{% endfor %}

{% if pageInfo.prevUrl %}
  <a href="{{ pageInfo.prevUrl }}">Previous</a>
{% endif %}
{% if pageInfo.nextUrl %}
  <a href="{{ pageInfo.nextUrl }}">Next</a>
{% endif %}

Profiling: Finding the Bottlenecks

Before you start optimizing, you need to know what's actually slow. Here are the tools I use:

Craft's Debug Toolbar

In devMode, Craft shows a debug toolbar at the bottom of every page. It shows you:

  • Total number of database queries
  • Time spent in each query
  • Memory usage
  • Twig rendering time

This is your first stop. If a page is running 200+ queries, you've got an N+1 problem. If one query is taking 500ms, you might need a database index.

The Yii Debug Module

For deeper profiling, enable the Yii debug module in your config/app.php:

config/app.php (debug module)
if (getenv('CRAFT_DEVMODE')) {
    $config['bootstrap'][] = 'debug';
    $config['modules']['debug'] = [
        'class' => 'yii\\debug\\Module',
        'allowedIPs' => ['*'],
    ];
}

This gives you a detailed profiling panel at /debug where you can see every query, every log entry, and request timing breakdowns.

Query Logging in Development

If you want to see every SQL query that Craft runs, you can temporarily add this to your config/db.php:

config/db.php
return [
    'enableSchemaCache' => true,
    'enableLogging' => getenv('CRAFT_DEVMODE'),
    'enableProfiling' => getenv('CRAFT_DEVMODE'),
];

Quick Wins Checklist

Here's my go-to list when I'm asked to speed up an existing Craft site. I work through these in order because each one builds on the last:

  1. Enable devMode and check the debug toolbar. Look at the query count. If it's over 50 on a single page, you've got eager loading to do.
  2. Add .with() calls to every listing template. Any template that loops through entries and accesses relational fields inside the loop needs eager loading.
  3. Add {% cache %} blocks around expensive partials. Sidebars, footers, navigation that queries entries.
  4. Check for .all() where .one() or .exists() would work. Quick grep through your templates will find these.
  5. Optimize image transforms. Use Imager X or Craft's built-in transforms to serve properly sized images. Don't serve a 4000px image in a 400px container.
  6. Turn on Craft's static page caching (or install Blitz) if the site doesn't have per-user dynamic content.
  7. Check your hosting. Sometimes the issue isn't the code. A $5/month shared host is going to be slow no matter what you do. Proper hosting with PHP opcache, a Redis-backed session store, and a CDN for static assets makes a big difference.

Performance work on Craft sites is usually about fixing a few key issues rather than rewriting everything. Eager loading alone can cut page load times in half on content-heavy pages. Layer in template caching and you're looking at a site that feels instant.

If your Craft site is feeling sluggish and you're not sure where to start, I'm happy to take a look. A quick audit usually reveals the top three or four things that'll make the biggest difference.