Skip to content

Caching & Edge

The fastest response is one you don’t compute

Section titled “The fastest response is one you don’t compute”

Static pages are already the ideal case: built once, served from a CDN, cached everywhere. The interesting question is what to do when a route is rendered on demand — you don’t want to re-run the render for every visitor if the result is the same for a while. That’s what caching is for: turn a repeated render into a cheap lookup.

For a route rendered on demand (prerender = false), you set caching headers on the response. The browser and any CDN in front of your server will then reuse the response instead of hitting your render again:

---
export const prerender = false;
// Cache this response at the CDN for 1 hour; allow serving a stale copy
// for a day while revalidating in the background.
Astro.response.headers.set(
'Cache-Control',
'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400'
);
const articles = await getArticles();
---
<ArticleList articles={articles} />

Proven end to end — a real Node server built with this exact header call, hit with curl -D -:

$ curl -s -D - http://127.0.0.1:4701/experiments/cache-header/ -o /dev/null
HTTP/1.1 200 OK
cache-control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
content-type: text/html

The key directives: s-maxage controls how long a shared cache (the CDN) keeps it, and stale-while-revalidate lets the CDN serve a slightly-stale copy instantly while it refreshes in the background — so users almost never wait on a cold render.

Prerendered pages: Astro.response.headers has nowhere to go

Section titled “Prerendered pages: Astro.response.headers has nowhere to go”

The same call on a page with export const prerender = true builds without any error or warning — but it’s a no-op. Proven: building that page produces a plain, flat .html file with no accompanying headers artifact anywhere in dist/ (no _headers file, nothing) — a static host serves that file with its own default headers, because there is no mechanism in a static build to carry a response header at all:

---
export const prerender = true;
Astro.response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
---
<p>prerendered — headers set here have nowhere to go</p>
<!-- the entire built file — no header sidecar exists anywhere in dist/ -->
<!DOCTYPE html><script type="module" src="/_astro/page.js"></script><p>prerendered — headers set here have nowhere to go</p>

If you need caching headers on a prerendered route, that’s a hosting/CDN concern (a _headers file for Netlify, a vercel.json rule, etc.) — Astro itself only wires Astro.response.headers through for on-demand routes, where there’s a real per-request Response for it to attach to.

A CDN caches responses at edge locations near users, so a cached page is served from a nearby city rather than your origin. A static Astro site is edge-cached by nature. An on-demand site becomes edge-friendly the moment you add the right Cache-Control — the CDN caches the rendered HTML at the edge and only calls your server when the cache expires.

flowchart LR
  user["User request"] --> edge["CDN edge"]
  edge -->|cache hit| fast["Serve cached HTML (fast)"]
  edge -->|cache miss| origin["Origin renders, then caches at edge"]
  origin --> edge
The edge serves cached HTML; the origin renders only on a miss

Route caching in config (Astro 7) — the real schema

Section titled “Route caching in config (Astro 7) — the real schema”

Astro 7 promoted route caching from an experimental flag to stable, top-level config. Read directly from the installed compiler (astro/dist/core/cache/config.js), the real schema is:

CacheProviderConfigSchema = { entrypoint: string | URL, name?: string, config?: Record<string, any> }
CacheSchema = { provider?: CacheProviderConfigSchema }
CacheOptionsSchema = { maxAge?: number, swr?: number, tags?: string[] }
RouteRulesSchema = Record<routePattern, CacheOptionsSchema>
astro.config.mjs
import { defineConfig, memoryCache } from 'astro/config';
export default defineConfig({
output: 'server',
cache: { provider: memoryCache() }, // built-in in-memory provider: { name: 'memory', entrypoint: 'astro/cache/memory' }
routeRules: {
'/blog/[slug]': { maxAge: 3600, swr: 86400, tags: ['blog'] },
},
});

memoryCache() (exported from astro/config, verified in astro/dist/config/entrypoint.js) is a single-instance, in-memory provider — fine for dev-like setups or a single server, not for a multi-instance deployment (each instance has its own cache). CDN platforms ship their own provider package for the real edge cache instead.

This is not the same header you set yourself — proven

Section titled “This is not the same header you set yourself — proven”

This is the part worth reading carefully: routeRules/cache do not set the browser-facing Cache-Control header at all. Reading astro/dist/core/cache/runtime/utils.js, a matched route’s maxAge/swr/tags are turned into a different, internal header:

// what routeRules actually produces, verified in astro/dist/core/cache/runtime/utils.js
headers.set('CDN-Cache-Control', `max-age=${maxAge}, stale-while-revalidate=${swr}`);
headers.set('Cache-Tag', tags.join(', '));

…and astro/dist/core/cache/handler.js deletes both of those headers before the response reaches the client (response.headers.delete('CDN-Cache-Control') / .delete('Cache-Tag')). They exist only to tell the configured cache.provider how long to keep this response — the provider intercepts the request, decides hit-or-miss, and the CDN/browser never sees CDN-Cache-Control at all.

Proven with a real request against a route with routeRules: { '/experiments/route-cache': { maxAge: 20 } } and cache.provider: memoryCache(), rendering a random UUID per render:

$ curl -s -D - http://127.0.0.1:4701/experiments/route-cache/
HTTP/1.1 200 OK
content-type: text/html
x-astro-cache: MISS
<p>5e3bae66-70b5-4adf-9236-ffa79ff2f9ec</p>
$ curl -s -D - http://127.0.0.1:4701/experiments/route-cache/
HTTP/1.1 200 OK
content-type: text/html
x-astro-cache: HIT
<p>5e3bae66-70b5-4adf-9236-ffa79ff2f9ec</p>

Same UUID both times (x-astro-cache flips MISS → HIT) — the second request never re-ran the page at all — and note there is no Cache-Control or CDN-Cache-Control header visible to the client in either response. If you also want browsers/downstream CDNs to cache the response, you still set Cache-Control yourself with Astro.response.headers.set(...), same as the section above — routeRules and a hand-set Cache-Control are two independent layers that happen to read the same maxAge/swr vocabulary.

One more thing verified directly in astro/dist/core/cache/handler.js: provideCache() swaps in a NoopAstroCache whenever runtimeMode === 'development' — routeRules/cache do nothing in astro dev, only in a built/production run. Test it with astro build + astro preview (or your adapter’s server), not astro dev.

Server islands: no default cache header, just X-Robots-Tag

Section titled “Server islands: no default cache header, just X-Robots-Tag”

Server islands (server:defer) are always on-demand (prerender: false unconditionally — verified in astro/dist/core/server-islands/endpoint.js). Reading that same file, the only header Astro itself sets on an island’s response is X-Robots-Tag: noindex — there’s no default Cache-Control at all. Caching (or explicitly not caching) an island is entirely up to the component:

---
// src/components/Greeting.astro — a server island
Astro.response.headers.set('Cache-Control', 'private, no-store');
const name = 'Ana';
---
<p>Welcome back, {name}</p>

Proven — the real request a page makes to fetch this island, and its real response:

$ curl -s -D - "http://127.0.0.1:4701/_server-islands/Greeting?e=...&p=&s="
HTTP/1.1 200 OK
cache-control: private, no-store
content-type: text/html
x-robots-tag: noindex
<p>Welcome back, Ana</p>

private, no-store came from the component’s own Astro.response.headers.set(...) — Astro did not add it. x-robots-tag: noindex is Astro’s, unconditionally, on every server island response (so a search engine never indexes the fragment URL).

The powerful pattern: cacheable shell + server islands

Section titled “The powerful pattern: cacheable shell + server islands”

Pure per-request SSR is hard to cache — if the page contains “Welcome back, Ana,” you can’t share that response with the next visitor. The elegant fix combines what earlier modules taught: make the page shell static/cacheable, and defer only the personalized part to a server island (server:defer).

---
// This page is static and fully cacheable at the edge...
---
<Layout>
<ArticleList /> <!-- same for everyone: cached -->
<UserGreeting server:defer> <!-- per-user: deferred, fetched separately -->
<p slot="fallback">Loading…</p>
</UserGreeting>
</Layout>

Now the heavy, shared part is cached at the edge and served instantly, while the tiny personalized island is rendered per request — and, as proven above, that island request carries its own cache decision (usually no-store), completely independent of the shell’s caching.

If a page is mostly the same for everyone with a few dynamic bits, static (or cached) + islands almost always beats full SSR: better cacheability, less server load, faster responses. Reserve full on-demand rendering for pages that are genuinely different on every request (a personalized dashboard, search results). Choosing the cacheable option is itself a performance decision.

The general Cloudflare adapter guide (docs.astro.build) documents automatic long-lived cache headers for hashed static assets, and direct access to the platform’s caches API (caches.default.put(request, response)) for hand-rolled edge caching — it does not itself describe a cache.provider integration. Astro’s own configuration reference, separately, lists Netlify, Vercel, and Cloudflare as platforms that ship an integrated cache.provider package for the cache/routeRules layer described above. I could not run a Cloudflare Worker in this environment to prove that provider end to end — cite the docs for the platform-specific provider, and verify the version you’re on before relying on it.

  • Does a snippet assume routeRules: { '/x': { maxAge: 60 } } alone makes browsers cache the response? Proven above: it drives an internal CDN-Cache-Control header that’s deleted before the client sees it — add an explicit Astro.response.headers.set('Cache-Control', ...) too if that’s the goal.
  • Is a cache-header call (Astro.response.headers.set) placed on a page that also has export const prerender = true? Proven above: it silently does nothing — move the header logic to hosting config, or drop prerender = true if the response genuinely needs to vary.
  • Does a server island assume it inherits the shell page’s Cache-Control? Proven above: Astro sets only X-Robots-Tag: noindex on the island response by default — anything cacheable (rare) or explicitly no-store (common) has to be set inside the island itself.
  • Is a caching claim being tested against astro dev? routeRules/cache are a no-op in dev — test against a built server.

🐛 Spot the bug

src/pages/dashboard.astro
---
export const prerender = true;
// "cache this for a day at the CDN"
Astro.response.headers.set('Cache-Control', 'public, max-age=86400');
const stats = await getDashboardStats();
---
<Dashboard stats={stats} />
Show the answer

The page is prerender = true — a static page built once, ahead of time. Proven above: a static build has no mechanism to carry a response header at all, so this Astro.response.headers.set(...) call builds without error but has zero effect; the deployed file is served with whatever default headers the host applies to a plain .html file, not this one. If stats genuinely needs to be fresh per visitor (which is why the author reached for a Cache-Control in the first place), the fix is export const prerender = false so there’s a real per-request response to attach the header to — or, if a day-old snapshot really is fine for everyone, drop the header entirely and let the static build’s own cacheability do the job.

There are two independent caching layers in Astro 7, and mixing them up is the most common mistake: Astro.response.headers is the plain-HTTP layer — a real header on a real per-request Response, meaningful only when there’s a request to attach it to (prerender = false). cache/routeRules is Astro’s own internal caching layer — it talks to your configured provider through headers (CDN-Cache-Control, Cache-Tag) that never reach the browser, deciding whether Astro re-runs your page at all. A server island is a small, separate on-demand request with its own independent cache decision, tagged X-Robots-Tag: noindex by Astro either way.

Astro.response.headers cache/routeRules Server island (server:defer)
Layer Real HTTP response header Astro’s internal cache, via a provider A separate on-demand sub-request
Visible to browser/CDN Yes No (CDN-Cache-Control/Cache-Tag stripped) Whatever the island itself sets
Works on a prerendered page No — silent no-op N/A (routing is always on-demand) N/A
Works in astro dev Yes No — disabled in dev Yes
Best for Direct control, or feeding a real CDN Sharing a cache policy across many routes via one config Personalizing one fragment of an otherwise cacheable page
  • “Setting routeRules: { maxAge: 60 } makes the browser cache the page for 60 seconds.” Proven above: it sets an internal CDN-Cache-Control header that’s deleted before the client ever sees it — the browser gets no caching instruction from routeRules alone.
  • “A Cache-Control header set anywhere in a page’s frontmatter works.” Proven above: it only works when the page is on-demand (prerender = false); on a prerendered page the call is a complete no-op, not a partial one.
  • “A server island response is cached the same way the shell page is.” Proven above: Astro sets only X-Robots-Tag: noindex on it by default — any caching (or explicit no-store) is the island component’s own responsibility.
  • “routeRules/cache work the same in astro dev as in production.” Verified in source: dev mode swaps in a no-op cache — you must test against a built server.
For an on-demand route, how do you let a CDN cache the response, and is this proven to actually reach the client?
What actually happens to that header?
---
export const prerender = true;
Astro.response.headers.set('Cache-Control', 'public, max-age=86400');
---
<p>hi</p>
What does routeRules: { '/x': { maxAge: 60 } } (with a cache.provider) actually set on the outgoing response, verified from source?
Do cache/routeRules do anything when running astro dev?
What header does Astro itself set by default on a /_server-islands/<name> response?
What is the "cacheable shell + server islands" pattern, and what is true about the island's own caching?