Meridian handbook
Templates

Caching

How the page cache and the cache tag for template fragments work, what a template does that makes a page or a block uncacheable, how cached output is invalidated on publish, and the configuration keys that bound it.

Two caches hold rendered output, and both are invalidated by content changes without any code of yours:

  • The page cache keeps whole public pages for anonymous visitors, serving a stale copy while one background render replaces it. It is off until engine.yaml turns it on.
  • The {% cache %} tag keeps one region of a template, keyed by what you name and registered against every document it read, so publishing one of them clears it.

Static files, built assets and image derivatives are cached by the browser and any CDN through their headers (Frontend build, Images).

The page cache

# config/engine.yaml
cache:
  pages:
    enabled: true
    fresh: 60s
    stale: 24h
    max_memory: 256MB
    concurrency: 2
    warm:
      paths: ["/"]
      top: 50
      deadline: 60s
KeyDefaultMeaning
cache.pages.enabledfalseTurn the page cache on
cache.pages.fresh60sHow long a page is served before it is rendered again; a publish ends it sooner
cache.pages.stale24hHow long past fresh a page may still be served while one background render replaces it
cache.pages.max_memory256MBThe bodies held in each process; the least recently read go first
cache.pages.concurrency2Background renders at once
cache.pages.warm.paths["/"]Paths rendered into the cache at boot, on every site, before the process reports ready
cache.pages.warm.top50Each site’s most visited pages of the last 30 days to warm as well (the sitemap’s first pages without analytics)
cache.pages.warm.deadline60sThe longest warming holds readiness back

The cache is per process. A publish, unpublish, trash, navigation change or tag change on any replica moves every replica’s cache generation within one invalidation poll (cache.invalidation_poll, 2 seconds by default); every page stored before is then stale, and pages read in the last 15 minutes are re-rendered in the background without waiting for a visitor. A body over 4 MB is not stored.

Responses carry X-Cache: hit, stale or miss.

Which pages are cached

A request is answered from the cache, and its response stored, only when all of these hold:

  • It is a GET or HEAD for a whole page: not a fragment or sigmx request, not a preview (?preview, ?token), not the profiler (?_profile), with no Authorization header.
  • No staff member and no member is signed in.
  • The response is 200, 404, 410, 301 or 308, is HTML, sets no cookie, has no Cache-Control saying private or no-store, and varies on nothing but Accept-Encoding.
  • The render printed nothing that belongs to one request.

The cache key is the host and the full address with its query string, so each page of results and each filtered list is a page of its own.

What a template does that stops a page being stored

In the templateWhy
A CSRF token printed for a signed-in visitor (csrfInput(), csrfToken(), csrf, a drawn form)The token is that visitor’s alone. For an anonymous visitor the helpers print nothing and a drawn form’s _csrf field is empty, so the page stays cacheable
The search route’s pageEach search is logged
A form for a members-only endpointThe page is sent Cache-Control: private, no-store
{% requireMember %}The page depends on who is signed in, and is sent as private

Keep them out of pages that should be fast for everyone: a site-wide newsletter form works for anonymous visitors without breaking the cache, because its token is empty for them.

The cache tag

{% cache 'news-card' key=article.id version=article.updatedAt ttl='1h' %}
  {% component 'teaser' doc=article %}
{% endcache %}

{% cache 'latest-news' ttl='10m' %}
  {% for a in query('article').orderBy('publishedAt', 'desc').limit(6).all() %}
    {% component 'teaser' doc=a %}
  {% endfor %}
{% endcache %}

{% cache 'results' vary=['query'] %}
  {% for item in items %}{% component 'teaser' doc=item %}{% endfor %}
{% endcache %}
{% cache 'name' key=... version=... ttl='...' vary=[...] %}...{% endcache %}
PartMeaning
'name'Required, a quoted literal: lower-case letters, digits and hyphens, starting with a letter. It is what the admin’s cache screen groups by and what a named purge clears
key=Any expression, a list included (key=[locale.code, request.path]); its value is part of the key
version=Any expression; its value is part of the key, so a new version is a new entry
ttl='90s', '10m', '1h' or '1d'; at most seven days. Without it, cache.page_ttl (300 seconds by default)
vary=A list of 'locale', 'path' (the request path) and 'query' (the query string, parameters sorted)

Every option is optional and written at most once.

What is always in the key

You cannot get these wrong, because the engine adds them: the site, the locale, the formatting locale, the template file and the line the block starts on (two blocks that share a name never collide), the route the page was served by, the block’s name, and the key, version and vary values. vary=['locale'] is accepted and adds nothing.

A block’s life, from the first render to the publish that clears it:

flowchart TD
  R["Render reaches cache block"] --> P{"A public page<br/>for a visitor?"}
  P -- no --> BODY["Render the body, store nothing"]
  P -- yes --> K["Key: site, locale, file, line,<br/>route, name, key, version, vary"]
  K --> H{"Stored and<br/>not invalidated?"}
  H -- hit --> OUT["Print the stored output"]
  H -- miss --> RB["Render the body"]
  RB --> PR{"Printed per-request output?<br/>token, member, focus, preload"}
  PR -- yes --> NS["Print it, do not store"]
  PR -- no --> ST["Store it against the documents<br/>and lists it read"]
  ST -.->|"a publish of one of them"| H

When a block is used

Only in a public page rendered for a visitor: an entry, an index, a landing page, search and the error pages. A preview, a fragment request, the admin, the member portal and mail render the body every time, so a draft or one member’s view is never stored. Outside those pages the tag simply renders its body.

Invalidation

A stored block is registered against cache scopes found by the render, not written by you, and is a miss once any of them changes:

What the block readCleared by
A document: an entry, a relation’s target, a link’s document, a list item, a component’s document propThat document’s publish, unpublish, scheduled publish, trash, restore or tagging change
An index page’s items, pagination or facetsAny change to a document of that type, which can add a result
smartList(...)The list’s own scopes
search(...)Any change to the site’s live content
AnythingA purge of the block’s name, its route or the site from the admin’s cache screen; a tag change (tags are printed everywhere)

The scopes come from the variables the body names (including {% set %} inside the block), followed four levels deep. A body that includes another template sees the whole context, so every document in it is registered; a component sees only its props. A block nested in another passes its scopes up.

A change the engine cannot see, such as an organisation’s details shown on a listing or an asset’s alt text, is bounded only by the block’s ttl. Set a ttl that is acceptable for those.

A block that read a document while a publish of it was in flight is not stored, so a block is never stamped with a revision it did not read.

What is never stored

A block whose render printed a CSRF token, read or required the signed-in member, emitted {% announce %} or {% focus %}, added an image preload (priority=true) or a video, is rendered and not stored. In development the engine logs which block and why.

engine-dmo check reports the obvious cases from the source as errors: csrfInput, csrfToken, csrf, now, enquiryForm, subscribeForm and {% requireMember %} inside a cache block.

_shared/article/index.html:12:1: error: cache block bad holds csrfInput, which emits per-request output: csrfInput() prints this visitor's CSRF token
      = move it outside the {% cache %} block, or leave the block uncached

It also refuses, at parse, a name that breaks the naming rule, a ttl over seven days or that is not a duration, an unknown vary value, and an unknown option.

Choosing what to cache

  • Cache regions that run queries: a query() strip, a smart list, a facet panel, a card grid. A single field print is not worth a block.
  • Give the block a key that names what makes it differ (the document’s id for a card), and a version that changes when it should be rebuilt (updatedAt).
  • Leave forms, the signed-in member and the current time outside the block.
  • Prefer the page cache for whole pages. Blocks help most on pages the page cache cannot keep: pages for signed-in members, and pages with query strings that vary widely.

Seeing it work

  • The admin’s cache screen shows, per block name, the hit rate, entries, size, age, scopes and the last invalidation, and purges by block name, route, document or site.
  • Under engine-dmo serve --dev, the profiler (open a page with ?_profile) shows each block’s hit, miss or skip with the reason.
  • In development (serve --dev) and with template.cache: false in engine.yaml, templates are parsed afresh on every render, so an edited template counts at once. In production, templates are compiled at boot and a template added or removed counts from the next restart.
  • engine-dmo warm requests each site’s warm paths, indexes and most visited pages from a running release after a deploy, and reports whether the page cache answered each and how long it took.