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.yamlturns 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 | Key | Default | Meaning |
|---|---|---|
cache.pages.enabled | false | Turn the page cache on |
cache.pages.fresh | 60s | How long a page is served before it is rendered again; a publish ends it sooner |
cache.pages.stale | 24h | How long past fresh a page may still be served while one background render replaces it |
cache.pages.max_memory | 256MB | The bodies held in each process; the least recently read go first |
cache.pages.concurrency | 2 | Background 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.top | 50 | Each site’s most visited pages of the last 30 days to warm as well (the sitemap’s first pages without analytics) |
cache.pages.warm.deadline | 60s | The 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
GETorHEADfor a whole page: not a fragment or sigmx request, not a preview (?preview,?token), not the profiler (?_profile), with noAuthorizationheader. - No staff member and no member is signed in.
- The response is
200,404,410,301or308, is HTML, sets no cookie, has noCache-Controlsayingprivateorno-store, and varies on nothing butAccept-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 template | Why |
|---|---|
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 page | Each search is logged |
| A form for a members-only endpoint | The 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 %} | Part | Meaning |
|---|---|
'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"| HWhen 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 read | Cleared by |
|---|---|
| A document: an entry, a relation’s target, a link’s document, a list item, a component’s document prop | That document’s publish, unpublish, scheduled publish, trash, restore or tagging change |
An index page’s items, pagination or facets | Any 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 |
| Anything | A 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
keythat names what makes it differ (the document’s id for a card), and aversionthat 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 withtemplate.cache: falseinengine.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 warmrequests 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.
Related
- Configuration reference: engine.yaml:
cache.*. - Forms in templates: tokens and anonymous visitors.
Frontend build
How stylesheets, scripts and static files reach a page through the assets helpers, with or without a Vite build, how the vite extension reads the build manifest and runs the dev server, and how to stay inside the content security policy with no inline script.
Testing templates
How to verify templates with engine-dmo check, the language server in your editor, describe template and describe route, render --json, and template tests in tests/templates/*.test.yaml with selector, JSON-LD and snapshot assertions.