Caching primer
Caching in this codebase isn't one system — it's a dozen small decisions made in different places, each reaching for whatever cache fit that problem. That's fine, but it means "how is X cached?" has no single answer until you know which kind of thing X is. This primer maps the caches to the problems they solve, so you can tell which one to reach for (and what invalidates it) without spelunking.
Just want the lookup table? Skip to the map.
The four questions
Almost every cache here is answering one of four questions. Find your question and you've found your cache.
1. "This is deterministic output that rarely changes."
Rendering artefacts that are stable most of the time — cache them hard, and invalidate structurally (watch the file) or with a long TTL as a backstop.
- The Vite manifest (
Vite/TagHelpers/ViteTagHelperBase.cs) — the entry-name→hashed-asset map is read once into a staticIMemoryCacheand invalidated by watching the file (IFileProvider.Watch), not by a clock. A deploy swapsmanifest.json, the watcher fires, the next request reloads it. No TTL, no restart. (See the frontend primer for the dev/prod side of this.) - Scoped inline SVG (
TagHelpers/SvgTagHelper.cs) — the sanitised, style-scoped SVG markup is deterministic per media path, so it's cached in Umbraco'sAppCaches.RuntimeCacheundersvg-scoped::{path}for 60 minutes. There's no media-change hook, so the 60-minute TTL is the backstop that lets a re-uploaded logo eventually show. This is the one place we use Umbraco's own cache abstraction rather thanIMemoryCache. → The whole pattern is the caching scoped SVG output tutorial.
2. "This is an API response I can serve stale for a while."
HTTP endpoint responses, cached by ASP.NET Core's output caching with named policies defined in Extensions/UmbracoBuilderExtensions.cs and bound to config via OutputCacheOptions. Endpoints opt in with [OutputCache(PolicyName = …)]. Two policies, two invalidation strategies:
ContentDriven— for endpoints backed by Umbraco content (the blog API). Long expiry, but tag-evicted: responses are taggedblog-content, andNotifications/BlogContentCacheInvalidationHandlercallsEvictByTagAsyncwhen a relevant content type is published or unpublished. So edits show up immediately, not after the TTL.ExternalApi— for data we don't control (Sessionize). Short, purely time-based expiry — there's no publish event to hang an eviction off, so you just accept a few minutes of staleness.
→ When [OutputCache] beats ResponseCaching, and how to be a polite consumer of a slow upstream, is the planned output-cache-policies tutorial.
3. "This is expensive to compute and I'll need it again this request — or next."
In-memory IMemoryCache, keyed by the inputs, invalidated when the underlying data changes. The richest example is Block Restrictions, which runs two tiers:
- a per-rule cache (30-minute sliding) so a content-tree walk doesn't hit the database at every ancestor, and
- a per-node resolved-result cache (60-second absolute) so a page with several restricted editors does the walk once.
The clever bit is invalidation: editing one rule can change the answer for every descendant node, so rather than track which nodes to evict, a static generation counter is baked into the cache key and bumped — every old key becomes unreachable at once (the orphaned entries are never read again and age out by TTL). → That's the subject of the content-tree-inherited config foundation; it's worth reading even if you never touch block restrictions, because the counter trick transfers.
4. "This comes from a slow or flaky third party and the page must not die when it's down."
The integration feeds all share one resilience shape: an in-memory cache for the happy path, backed by a stale fallback (a longer-lived copy, often persisted to disk) that gets served when the upstream errors. Better a few hours stale than a 500.
- Sessionize (
Features/Sessionize/Infrastructure/SessionizeApiClient.cs) —IMemoryCachekeyedsessionize_all_{eventId}forCacheDurationInMinutes, backed by an on-disk JSON fallback that's served whenever the API is unreachable (no age limit — stale data beats no programme). - Community Blogs and Calendar Feed (
Features/Feeds/) — the same primary-plus-stale shape, but with a bounded stale window: Community Blogs adds a background refresh service and a disk copy kept for 30 days; Calendar keeps a 7-day stale copy.
→ The integrations primer covers these feeds from the integration angle; this entry is just their caching shape.
A couple that don't fit the four
- 404 suggestions (
Services/PageNotFoundSuggestionService.cs) — caches suggestions per{tenant}:{culture}:{max}:{query}with an outcome-dependent TTL (a day for hits, an hour for empty results, a minute for failures) plus a concurrency limiter so a search outage can't stampede the index. - 404-tracker ignore rules (
Umbraco.Community.NotFoundTracker/Matching/) — not a keyed cache at all but an immutable snapshot held behind a volatile reference and atomically swapped on change. No TTL, no per-lookup cost; the whole rule set is rebuilt and replaced when a rule mutates.
The map
| Caching it because… | Mechanism | Where | Lifetime | Invalidated by |
|---|---|---|---|---|
| Built at deploy time (assets) | static IMemoryCache |
Vite manifest | until file changes | IFileProvider.Watch |
| Deterministic render output | AppCaches.RuntimeCache |
SVG TagHelper | 60 min | TTL |
| Content-backed API response | OutputCache ContentDriven |
blog API | long (config) | tag eviction on publish |
| Uncontrolled API response | OutputCache ExternalApi |
Sessionize API | minutes (config) | TTL |
| Expensive cross-request compute | IMemoryCache ×2 |
Block Restrictions | 30 min sliding / 60 s | per-key remove / generation counter |
| Slow third-party data | IMemoryCache + disk/stale |
Sessionize, feeds | config + long stale | TTL; stale served on error |
| Per-query 404 suggestions | IMemoryCache |
suggestion service | 1 d / 1 h / 1 min | outcome-based TTL |
| Match rules, read-hot | volatile snapshot | NotFoundTracker | none | atomic swap on change |
Choosing one
- Deterministic output of a pure-ish function? Cache it by its inputs (
IMemoryCache, orRuntimeCacheif it's Umbraco-content-derived). The SVG and Vite caches are this. - An HTTP endpoint? Reach for
[OutputCache]with a policy — don't hand-roll an in-memory cache in the controller. Tag it if a content event can invalidate it; time-box it if nothing can. - Something a write should invalidate broadly? Don't enumerate what to evict — put a generation counter in the key (Block Restrictions) and let the old entries age out.
- Third-party data? Always pair the cache with a stale fallback. The question isn't "how fresh" but "what do I serve when it's down."
A note on what we don't use: Umbraco also offers AppCaches.RequestCache (per-request memoisation) and IsolatedCaches (per-content-type). They're reasonable tools, but nothing in this codebase reaches for them today — so if you're tempted, you're breaking new ground, not following a pattern.
Related docs
- Caching the scoped SVG output —
RuntimeCachekeyed by media path, and why the output is deterministic enough to cache. - Configuration that inherits down the content tree — the two-tier cache and the generation-counter invalidation trick, in full.
- Backend primer → Output caching — where the policies sit in the bootstrap and request flow.
- Integrations primer — the third-party feeds whose stale-fallback caching appears under question 4.
Cache to the shape of the problem, invalidate on the event that actually changes the answer, and you'll stay out of the two classic traps — serving stale forever, and caching nothing at all.