Route caching
- Accepted Date: 2025-03-19
- Reference Issues/Discussions: https://github.com/withastro/roadmap/discussions/1131
- Author: @ascorbic
- Champion(s): @ascorbic
- Implementation PR:
Astro route caching
Summary
Introduce a platform-agnostic route caching API for Astro SSR pages. This feature enables developers to declaratively define caching rules per route and dynamically override them in middleware or API handlers. It provides a consistent API across Node.js, Vercel, Netlify, Cloudflare, and other deployment targets, ensuring efficient caching while allowing cache invalidation by path or tag when supported. Where possible it will be implemented using CDN caches, but with fallback implemntations where this isn't supported.
Background & Motivation
Currently, caching in Astro SSR requires manually setting Cache-Control or CDN-Cache-Control headers, which vary per platform and is not ergonomic. Many modern frameworks offer built-in support for SSR caching strategies, allowing pages to be served efficiently while reducing function invocations and improving performance.
Astro's current lack of a unified caching API means that users must implement custom caching solutions per deployment target, leading to unnecessary complexity. This proposal introduces a framework-level abstraction for caching that integrates seamlessly with Astro’s adapter system, providing an intuitive DX while using platform-specific caching mechanisms where available.
Goals
- Provide a platform-agnostic caching API for Astro SSR routes and server islands.
- Support TTL, stale-while-revalidate (SWR), and ETag-based caching.
- Cache in the CDN where possible, using HTTP cache or
CacheStorage. - Allow declarative caching definitions in config, with overrides in Astro pages and server islands.
- Enable imperative cache invalidation via by path and tag for fine-grained control.
- Use platform-specific caching mechanisms (CDN, edge cache, etc.) via Astro adapters where available.
- Maintain a consistent API and DX across deployment targets.
Non-goals
- Persistent cache storage in core. This is delegated to the CDN.
fetchcache. This is for responses only.- Page fragment cache, except for server islands. This is designed for caching complete responses.
- Browser cache handling, except for setting etag and respecting conditional requests.
- Cache rules for prerendered pages.
Example
Declarative API
Cache rules can be declared via route patterns in config:
// astro.config.mjs
export default {
cache: {
routes: {
'/': { maxAge: 0, swr: 60 },
'/blog/**': { maxAge: 300 },
'/api/**': { maxAge: 600 },
}
}
};
They can be overridden in an Astro page or island's frontmatter:
---
Astro.cache({
maxAge: 300, // Cache for 5 minutes
swr: 3600, // Allow stale content for 1 hour while revalidating. In seconds or boolean. Default `true`, meaning 1 hour.
tags: ['blog', 'blog:1'], // Optional cache tags for invalidation
});
---
<html>…</html>
Invalidation
Helper functions allow cache invalidation where the adapter supports it:
// In an API handler or webhook:
import { invalidate } from "astro:cache"
invalidate({
path: '/blog/my-article', // Invalidate a single page
tag: ['blog', 'home'] // Invalidate all pages tagged as "blog" or "home"
});
This uses adapter hooks under the hood, which call the platform-specific APIs. By providing a unified API, Astro ensures that caching is easy to configure while remaining adaptable to different hosting environments.
Adapter support
Each platform has slightly different levels of support, so would need to declare this via adapter features. For example, some platforms don't support cache tags and some don't support invalidation.
Where possible, adapter support would be implemented with CDN-Cache-Control headers. The cache config could allow users to specify the header names, for example.
We would implement an optional lightweight cache for the Node adapter using in-memory LRU, but would recommend using an external proxy or CDN cache.
I don't like that the cache invalidation is done in a different way than setting the cache. I'd prefer Astro.cache.invalidate()
How would this work for static pages? Or would it be an error to set cache in config without an adapter?
I don’t love seeing routing config in astro.config.mjs as we don’t really have a precedent for that and it always makes things harder to reason about when there are two sources of truth (one in config and one in the .astro route itself). Is it required?
@florian-lefebvre Yeah, that's fair. When I originally did that API, I was thinking that we'd want to allow invalidation outside of Astro pages, but that would be hard to do anyway.
@delucis it would be an error, like trying to access headers. We already allow redirects in config, so there is precedent there.
Ah, true. I also don’t love redirects in config, but I guess I’ll have to bite my tongue 😂
I'm not totally sold on the idea either, tbh, particularly because the matching could be weird if the patterns don't match with the routes. However it could be quite laborious having to manually set it on every page, so a way to do it in bulk is nice.
I guess it would be possible to implement with middleware instead if you wanted to match patterns?
export const onRequest = ({ url, cache }, next) => {
if (url.pathname.startsWith('/api/')) {
cache({ maxAge: 600 });
}
return next();
}
That’s also more flexible than globs in situations where you want e.g. api/** has one rule, except for api/some-special-case which needs another, where then with globs you’d need some priority/specificity rules potentially to handle that.
That's not great DX, and won't work with edge middleware
Ah, forgot about edge middleware. Annoying. (Although we could presumably work around it by passing state from edge middleware to the underlying handler? It’s all serializable state after all.)
I’m not sure it’s necessarily worse DX given that understanding how different globs interact is not all that easy to reason about (are they merged? does higher specificity win? is config order important? etc.)
Example:
// astro.config.mjs
export default {
cache: {
routes: {
'/blog/**': { maxAge: 3600, tags: ['blog'] },
'/blog/categories/**': { maxAge: 600 },
'/**': { maxAge: 0, swr: 60, tags: ['everything'] },
}
}
};
Does /blog/categories/** get { maxAge: 600 } or { maxAge: 600, tags: ['blog'], swr: 60 } or { maxAge: 600, tags: ['blog', 'everything'], swr: 60 }? Or does order of definition somehow matter and the /** entry applies?
Admittedly it doesn’t have to be either/or here. Even if there’s a config version of the cache rules, you could presumably still control stuff from middleware. But we’d still have to decide how globs like that are resolved.
Generally the rule is most specific wins. The other option is to use arrays rather than objects, so the ordering is explicit
I think the clearest would be to use the same precedence rules as our routing
So no merging, fully verbose config for each pattern would be required 👍
// astro.config.mjs
export default {
cache: {
routes: {
'/**': { maxAge: 0, swr: 60, tags: ['everything'] },
'/blog/**': { maxAge: 3600, swr: 60, tags: ['blog', 'everything'] },
'/blog/categories/**': { maxAge: 600, swr: 60, tags: ['blog', 'everything'] },
}
}
};
Yeah, I think keeping it explicit is best
What do you think about exposing an api for components to read the cache tags for the page they're on? Similar to Astro.props, just for getting the cache tags for example. In our case it could be used to know when to trigger invalidation or not. As our cache tags are opaque for security reasons. Having an Astro.cache.getTags would let us determine when it's appropriate to invalidate a tag or when to ignore a live event 😌
Yes, I think that makes sense. I would also like to expose them to integrations, so you could in theory create an integration that abstracts that for your users
Love to see this land, and possibly help beta test it before and give feedback. Is there any ETA before v6?
For caching on Cloudflare, I think its worth investigating how OpenNext does it - https://opennext.js.org/cloudflare/caching
Continue the conversation in the stage 3 RFC https://github.com/withastro/roadmap/pull/1245