Skip to content

Route Caching

Astro’s route caching stores the rendered response of an on-demand page, so a cache hit skips rendering. EmDash supplies cache tags for the content a page renders and invalidates those tags when the content changes. Pages can stay cached for days while a publish usually becomes visible within seconds.

The object cache stores query results and still renders every page. With a cache provider configured, anonymous renders skip the object cache and read from the database, so a render that refills an invalidated page doesn’t come from an older snapshot (see What gets cached). The route cache then serves anonymous visitors, and the object cache mainly speeds up renders for signed-in editors.

Route caching needs a cache provider. Use the one that matches your deployment:

Deployment Provider Import from
Node.js memoryCache() astro/config
Cloudflare cacheCloudflare() @astrojs/cloudflare/cache

memoryCache() keeps entries in the memory of the server process. cacheCloudflare() is a CDN provider: it stores responses in the Cloudflare Workers Cache, and a cache hit never runs the Worker. Astro’s Netlify and Vercel adapters ship CDN providers as well; see Adapter cache providers. EmDash invalidates through Astro’s provider-independent cache.invalidate() API, so its tags work with any provider. Provider differences covers the behavior that depends on the provider. astro dev never caches, so test caching against a production build.

Register the provider and declare which routes may be cached, for how long, and under which tags. This example uses the Cloudflare provider; on Node, use cache: { provider: memoryCache() } with memoryCache imported from astro/config:

astro.config.mjs
import { defineConfig } from "astro/config";
import { cacheCloudflare } from "@astrojs/cloudflare/cache";
export default defineConfig({
// ...your existing output, adapter, and integrations
cache: {
provider: cacheCloudflare(),
},
routeRules: {
"/": { maxAge: 86_400, swr: 604_800, tags: ["posts", "pages", "all"] },
"/blog": { maxAge: 86_400, swr: 604_800, tags: ["posts", "all"] },
"/blog/[slug]": { maxAge: 86_400, swr: 604_800, tags: ["posts", "all"] },
"/sitemap.xml": { maxAge: 86_400, swr: 604_800, tags: ["posts", "pages", "all"] },
"/sitemap-[collection].xml": { maxAge: 86_400, swr: 604_800, tags: ["posts", "pages", "all"] },
},
});
  • maxAge is how long the cache serves the entry before the route renders again. EmDash invalidates stale entries on publish (below), but an invalidation can fail, so maxAge also bounds how long a page can stay stale, plus one stale response while swr revalidates it. Check Provider differences before choosing a long value.
  • swr lets the cache keep serving a stale entry while it revalidates in the background, so visitors rarely wait on a render once a page is cached.
  • tags name the entry for invalidation. Tag each route with the collections it renders, using the collection names: EmDash’s built-in invalidation uses collection names, so matching tags are invalidated automatically. A broad extra tag on every cached route (here all, which is not a collection name, so EmDash never invalidates it on its own) works as an emergency switch: invalidating it, for example with context.cache.invalidate({ tags: ["all"] }) in an endpoint you protect yourself, clears everything at once.

The query helpers also return a cacheHint you can pass to Astro.cache.set() inside the page. Its tags name the exact entry (getEmDashEntry), the collection (getEmDashCollection), or the chrome resource (getMenuWithCacheHint, getSiteSettingsWithCacheHint, getTaxonomyTermsWithCacheHint, getWidgetAreaWithCacheHint). These are the same tags EmDash invalidates on writes, so a page that passes its hints is invalidated when an entry or chrome resource it renders is written, including ones its route rule doesn’t name:

src/pages/blog/[slug].astro
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
if (!slug) return Astro.rewrite("/404");
const { entry, cacheHint } = await getEmDashEntry("posts", slug);
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

Astro.cache.set() merges with the route rule, and tags accumulate across calls. The cache hints carry tags but no maxAge, so the route rule decides how long a page is cached.

Give every public route an explicit rule. Rules match the request path, so list your public routes individually: a rest parameter at the site root, such as /[...slug], also matches the admin and API routes under /_emdash/.

EmDash’s content API routes invalidate the route cache whenever live content changes: publishing, unpublishing, deleting, or restoring an entry from the trash, and saving changes that reach the live entry, invalidate the tags [collection, id]. Creating or duplicating an entry invalidates nothing, because the new entry is a draft. Any route rule tagged with the collection name (as above) is therefore usually refreshed within seconds of a publish, even with a long maxAge.

Site chrome is covered too: saving site settings invalidates emdash:settings, menu edits invalidate emdash:menu:{name}, and taxonomy and widget-area writes invalidate their matching emdash:taxonomy:{name} / emdash:widget-area:{name} tags. Pages pick these tags up automatically when they use the cacheHint from getMenuWithCacheHint(), getSiteSettingsWithCacheHint(), getTaxonomyTermsWithCacheHint(), or getWidgetAreaWithCacheHint().

Creating, editing, or deleting a redirect invalidates the cached page at the redirect’s source path, with and without a trailing slash, so the redirect applies on the next request.

Restoring an older revision, and changes to bylines, the media library, and comments, invalidate nothing. Cached pages affected by them update once they expire or the entry that renders them is saved again. Scheduled publishing invalidates the published entries’ tags on Cloudflare only; see Provider differences.

This is also why the tag convention matters: EmDash invalidates collection names, entry ids, and the emdash:* chrome tags. If your route rules use other tag names, the built-in invalidation will not match them and stale entries survive until maxAge expires.

Route rules match the request path, so they also apply to responses you do not want cached under a long TTL. EmDash keeps responses rendered for a signed-in user, and responses sent with Cache-Control: private or no-store, out of the route cache automatically, and 404 responses too. Other error responses and your own middleware redirects are the template’s job, opted out per request with Astro.cache.set(false).

Astro.cache.set(false) only holds until the next Astro.cache.set() call. A layout renders after the page frontmatter, so a layout that sets a cache hint turns caching back on for a page that opted out. Pass the opt-out to the layout (for example as a prop) and skip its Astro.cache.set() calls when it is set.

Section titled “Preview and edit links are handled automatically”

EmDash’s middleware opts these responses out of the route cache itself, so no template code is needed: requests carrying a ?_preview= token (valid or not, because those URLs are per-token and a cached draft would otherwise stay reachable after the token expires), and responses with the editor toolbar injected. With toolbar: "client", the same applies to ?_edit URLs. Valid preview responses and toolbar-injected responses are additionally marked Cache-Control: private, no-store for browsers and downstream CDNs.

EmDash keeps every 404 out of the route cache, so publishing a missing entry later shows it right away. Other error responses, such as a 500 rendered on a cached route, inherit the rule’s TTL. Opt them out with Astro.cache.set(false).

Middleware that short-circuits with a redirect (for example, host canonicalization) must opt out with context.cache?.set(false). Otherwise the redirect is cached under the route rule’s TTL, and on a CDN provider that doesn’t separate cache entries by hostname it can be served to requests for the canonical hostname too, causing an instant redirect loop. Use optional chaining: context.cache is undefined on some responses, such as the 404 page for a URL that matches no route.

The route cache stores one shared variant per URL, and its cache key ignores cookies, so a cache hit never reflects an editor’s session. With the default server-rendered toolbar, an editor browsing the public site is usually served the cached anonymous variant, without the toolbar, and the emdash-edit-mode cookie has no effect on a cached URL. A page rendered for a signed-in editor is never stored in the shared cache; the next anonymous visitor fills it.

Set toolbar: "client" for sites that use route caching. A small script then shows the edit pill to signed-in editors on cached pages, and the edit view loads from an ?_edit URL that is always rendered fresh. Preview links bypass the cache in either mode.

  • Each server process keeps its own cache, and an invalidation only clears the process that handled the write. If you run several processes or instances, the others keep serving their cached pages until maxAge expires.
  • A restart empties the cache.
  • memoryCache() only stores a response that has a maxAge, so a route without a rule is never cached, even if the page calls Astro.cache.set() with a cache hint.
  • Scheduled publishing does not invalidate the route cache on Node. A page that lists a scheduled entry shows it once its cached copy expires, so keep maxAge short on routes that list scheduled content.
  • The provider writes Cloudflare-CDN-Cache-Control and Cache-Tag response headers. The Workers Cache consumes both; clients never see them, and any Cache-Control header your pages set for browsers is unaffected.
  • When a cache provider is configured, the @astrojs/cloudflare adapter enables the Workers Cache automatically: the build writes "cache": { "enabled": true } into the generated Wrangler configuration that wrangler deploy picks up.
  • A route without a rule is marked no-store, but only if nothing calls Astro.cache.set() while it renders. A cache hint without maxAge, like the ones from getSiteSettingsWithCacheHint() and getMenuWithCacheHint(), produces Cloudflare-CDN-Cache-Control: public without a lifetime, so the Workers Cache falls back to its default cache lifetimes (2 hours for a 200 response). If your layout sets these hints, every page that uses the layout becomes cacheable, with or without a rule.
  • By default the Worker version is part of the cache key, so every deploy starts with an empty cache and the first requests after it render in the Worker. To keep entries across deploys, set "cache": { "enabled": true, "cross_version_cache": true } in your Wrangler configuration (see Cross-version caching). Template changes then only show once the affected entries expire or are invalidated.
  • Scheduled publishing invalidates the tags of the entries it publishes from the Cron Trigger.

The Cloudflare provider invalidates by purging the Workers Cache. Workers Cache purges always use Cloudflare’s Free plan purge limits, whatever plan your account is on: 5 requests per minute with a burst of 25, per account (see Purge rate limits). Most content writes cost one purge request each: saves that change live content (including autosaves in collections that save directly to the live entry), publishing, unpublishing, deleting, restoring, scheduling, and assigning taxonomy terms to an entry, plus every settings, menu, taxonomy, or widget edit. A redirect write costs one purge request per source path variant, so up to four when a redirect’s source changes.

A rate-limited purge fails without an error in the admin, and the affected pages keep their cached version until maxAge expires. During bulk edits, or with several editors working at once, choose a maxAge you can accept as the worst case for stale content.

The route cache can afford long TTLs because EmDash invalidates it; browser caches are not invalidated. When a page passes a cache hint with a last-modified date (as entry and collection hints do) and sets no Cache-Control itself, Astro sends Cache-Control: no-cache, so browsers revalidate on every visit. If you set your own header, for example with Astro.response.headers.set("Cache-Control", ...), keep it short (minutes, not days), so a publish also reaches returning visitors quickly. stale-while-revalidate in that header gives returning visitors instant loads while their browser revalidates in the background.