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.
Cache-Control on on-demand responses
Section titled “Cache-Control on on-demand responses”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/nullHTTP/1.1 200 OKcache-control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400content-type: text/htmlThe 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.
Edge caching
Section titled “Edge caching”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
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>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.jsheaders.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 OKcontent-type: text/htmlx-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 OKcontent-type: text/htmlx-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 islandAstro.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 OKcache-control: private, no-storecontent-type: text/htmlx-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.
When static-with-islands beats full SSR
Section titled “When static-with-islands beats full SSR”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.
Cloudflare and other adapters
Section titled “Cloudflare and other adapters”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.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a snippet assume
routeRules: { '/x': { maxAge: 60 } }alone makes browsers cache the response? Proven above: it drives an internalCDN-Cache-Controlheader that’s deleted before the client sees it — add an explicitAstro.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 hasexport const prerender = true? Proven above: it silently does nothing — move the header logic to hosting config, or dropprerender = trueif the response genuinely needs to vary. - Does a server island assume it inherits the shell page’s
Cache-Control? Proven above: Astro sets onlyX-Robots-Tag: noindexon the island response by default — anything cacheable (rare) or explicitlyno-store(common) has to be set inside the island itself. - Is a caching claim being tested against
astro dev?routeRules/cacheare a no-op in dev — test against a built server.
🐛 Spot the bug
---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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”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 |
Common Misconceptions
Section titled “Common Misconceptions”- “Setting
routeRules: { maxAge: 60 }makes the browser cache the page for 60 seconds.” Proven above: it sets an internalCDN-Cache-Controlheader that’s deleted before the client ever sees it — the browser gets no caching instruction fromrouteRulesalone. - “A
Cache-Controlheader 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: noindexon it by default — any caching (or explicitno-store) is the island component’s own responsibility. - “
routeRules/cachework the same inastro devas in production.” Verified in source: dev mode swaps in a no-op cache — you must test against a built server.