Guide 12 · Developer docs

Performance, caching, deployment and go-live

How Core dna caches pages, what makes a render fast, how code reaches a site and what stays behind, the go-live checklist, and running a site once it is live. For developers, leads and site operators who already build pages in a theme repository.

L2–L3
Unit 02 · Making pages fast

Fetching once instead of per iteration

How to fix each query-per-row shape: one lookup with an in filter and a map, a list rendered from the search index, and a shared value resolved once for a whole component tree, with the query counts before and after.

Who this is for. Theme developers fixing a slow listing or a heavy component page. Afterwards you can rewrite each of the three query-per-row shapes so the expensive work happens once, and know which fix fits which shape.

Each of the three query-per-row shapes has the same cure: do the expensive thing once, before the loop, and look the answer up inside it. What "once" looks like differs per shape. Count queries, not milliseconds: a query takes a few milliseconds on a quiet database and many more on a busy one, so the count is what predicts how the page behaves under load. Then time the page before and after on the same environment to confirm it.

First, is it worth it

A page served from the page cache renders once per stored copy, not once per visitor (the page cache). A listing of 30 posts that costs 61 queries on a public, cacheable page pays them once until the copy is cleared. The same listing on a page that renders every time, such as a member page or one that prevents caching, pays them on every view. Fix those first, then the longest lists, then the rest.

A lookup in a loop: one query and a map

Collect the keys in a first pass, fetch every record in one call with the | (in) operator, and index the result by key. The loop then reads from the index:

Template code
{* 1. collect the keys *}
{$slugs = []}
{foreach $page->getAreaCollectionElements('cards') as $card}
  {$slugs[] = $card->getAreaText('category')}
{/foreach}

{* 2. one query, indexed by slug; an empty list must not reach the query *}
{$categories = []}
{if $slugs}
  {foreach $_query->findBy('Common\Entities\Category', ['|slug' => $slugs]) as $category}
    {$categories[$category->getSlug()] = $category}
  {/foreach}
{/if}

{* 3. the loop reads from the index *}
{foreach $page->getAreaCollectionElements('cards') as $card}
  {$slug = $card->getAreaText('category')}
  {if isset($categories[$slug])}
    <a class="card" href="/products/{$slug|escape:'url'}">{$card->getAreaText('title')|escape} <span>{$categories[$slug]->getName()|escape}</span></a>
  {/if}
{/foreach}
{# 1. collect the keys #}
{% set cards = page.getAreaCollectionElements('cards') %}
{% set slugs = [] %}
{% for card in cards %}{% set slugs = slugs|merge([card.getAreaText('category')]) %}{% endfor %}

{# 2. one query, indexed by slug; an empty list must not reach the query #}
{% set categories = {} %}
{% if slugs is not empty %}
  {% for category in _query.findBy('Common\\Entities\\Category', {'|slug': slugs}) %}
    {% set categories = categories|merge({('c-' ~ category.getSlug()): category}) %}
  {% endfor %}
{% endif %}

{# 3. the loop reads from the index #}
{% for card in cards %}
  {% set slug = card.getAreaText('category') %}
  {% if categories['c-' ~ slug] is defined %}
    <a class="card" href="/products/{{ slug|e('url') }}">{{ card.getAreaText('title')|e }} <span>{{ categories['c-' ~ slug].getName()|e }}</span></a>
  {% endif %}
{% endfor %}

Twelve cards go from 12 category queries to 1. Three details carry weight:

  • An empty list is an error, not an empty result: the query refuses an in filter with nothing in it ("Cannot add comparison with empty array for field slug"), and the page fails. Always guard the call.
  • In Twig, prefix the key. merge renumbers numeric keys, so prefix a map key that might be numeric, such as an id or a slug that happens to be a number, with text such as 'c-' ~; otherwise the key is renumbered and the lookup misses. Smarty's array assignment keeps keys as given.
  • The same works for ids: ['|id' => $ids]. Only the record types $_query allows can be fetched this way; people and users are not among them, and asking for one is an error that fails the page.

A relation in a loop: render the list from the search index

A post's authors and categories cannot be fetched in bulk from a template: to know which belong to which post, each post's relation has to be loaded. The way round it is not to read them from the database at all. The search index already holds each document's name, URL, first author's display name, tag and category names and any mapped fields, so a listing run as a search view, with no query and a sort, gets everything for the whole list in one request to the index (running a view from a template):

Template code
{$latest = $_search->search('latest-posts')}
{foreach $latest->getResultItems() as $item}
  <article>
    <h3><a href="{$item->getText('fullPath')|escape}">{$item->getText('name')|escape}</a></h3>
    <p>{$item->getText('author.displayName')|escape} &middot; {$item->getText('categories.name')|escape}</p>
  </article>
{/foreach}
{% set latest = _search.search('latest-posts') %}
{% for item in latest.getResultItems() %}
  <article>
    <h3><a href="{{ item.getText('fullPath')|e }}">{{ item.getText('name')|e }}</a></h3>
    <p>{{ item.getText('author.displayName')|e }} &middot; {{ item.getText('categories.name')|e }}</p>
  </article>
{% endfor %}

Thirty posts go from at least 61 database queries to one request to the index. getText() joins a list with commas, so categories.name prints every category; the index keeps only the first author's name. The trade-offs are the index's: a new post appears only once the indexer has processed it, and the view's settings decide which records it can return (what gets indexed). For a public "latest" list those are usually acceptable. Where they are not, the other options are to show the relation on the detail page instead of the listing, or to accept the cost on a page the cache serves.

A nested tree: resolve shared values once, at the top

Every component receives the $_templateContext helper itself, the same way it receives $page and the platform's other underscore helpers. So the page template can resolve a value once and store it there, and every component below reads it back from the helper instead of looking it up again. In Twig, set stores the value there for you; in Smarty you call set(). Read it back with get() in the component, since it is the helper itself that is passed down, not the values stored inside it:

Template code
{* the page's layout template, before the sections are rendered *}
{$_templateContext->set('pageTheme', $page->getAreaText('theme'))}
{$_templateContext->set('primaryCta', $_globalComponents->get('primary-cta'))}
{$page->getAreaHtml('sections')}

{* components/card.html: read back what the page resolved *}
{$pageTheme = $_templateContext->get('pageTheme', 'light')}
{$primaryCta = $_templateContext->get('primaryCta')}
<div class="card card--{$pageTheme|escape}">
  <h3>{$component->getAreaText('heading')|escape}</h3>
  {if $primaryCta}{$primaryCta->getHtml()}{/if}
</div>
{# the page's layout template, before the sections are rendered #}
{% set pageTheme = page.getAreaText('theme') %}
{% set primaryCta = _globalComponents.get('primary-cta') %}
{{ page.getAreaHtml('sections') }}

{# components/card.html: read back what the page resolved #}
{% set pageTheme = _templateContext.get('pageTheme', 'light') %}
{% set primaryCta = _templateContext.get('primaryCta') %}
<div class="card card--{{ pageTheme|e }}">
  <h3>{{ component.getAreaText('heading')|e }}</h3>
  {% if primaryCta %}{{ primaryCta.getHtml() }}{% endif %}
</div>

Ninety-six lookups become two. Keep the component working when a value is not set (the default above), so it still renders on a page whose template does not store it, or in the admin's preview.

Before and after

ShapeExampleBeforeAfter
Lookup in a loop12 cards, each finding its category12 queries1
Relation in a loop30 posts with authors and categoriesat least 61 queries1 index request
Shared value in a tree48 cards, each reading the theme and a global component96 lookups2

Time the page before and after on the same environment (Measure first). On a development environment the absolute numbers are inflated, but the difference between the two versions is real.

Go deeper

Was this page helpful?

Your answer goes to the docs team.