Performance Tuning Craft CMS: Eager Loading, Caching, and Query Optimization
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:
{# 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:
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:
{# 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:
{% 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:
{% set entries = craft.entries
.section('blog')
.with([
['featuredImage', {
withTransforms: [
{ width: 800, height: 450, mode: 'crop' },
{ width: 400, height: 225, mode: 'crop' },
]
}],
])
.all() %}
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.
{# 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 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 %}
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.
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:
{# 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() %}
.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:
{# 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
{# 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:
{% 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:
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:
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:
- Enable
devModeand 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. - Add
.with()calls to every listing template. Any template that loops through entries and accesses relational fields inside the loop needs eager loading. - Add
{% cache %}blocks around expensive partials. Sidebars, footers, navigation that queries entries. - Check for
.all()where.one()or.exists()would work. Quick grep through your templates will find these. - 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.
- Turn on Craft's static page caching (or install Blitz) if the site doesn't have per-user dynamic content.
- 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.