Skip to content

Comments

Route Caching#1245

Open
ascorbic wants to merge 10 commits intomainfrom
feat/route-caching
Open

Route Caching#1245
ascorbic wants to merge 10 commits intomainfrom
feat/route-caching

Conversation

@ascorbic
Copy link
Contributor

@ascorbic ascorbic commented Oct 15, 2025

Summary

A platform-agnostic route caching API for Astro SSR pages that enables declarative cache control using web standards.

Examples

Basic route caching

---
// src/pages/products/[id].astro
import { getEntry } from 'astro:content';
const product = await getEntry('products', Astro.params.id);
Astro.cache.set({
  lastModified: product.updatedAt,
  maxAge: 300,  // Cache for 5 minutes
  swr: 3600,    // Stale-while-revalidate for 1 hour
  tags: ['products', `product:${product.id}`]
});
---
<h1>{product.data.name}</h1>
<p>{product.data.description}</p>

Automatic dependency tracking

// src/pages/api/revalidate.ts
import { getLiveEntry } from "astro:content";

export const POST: APIRoute = async ({ cache, request }) => {
  const { id } = await request.json();
  const { entry } = await getLiveEntry("products", id);

  // Invalidate all pages that depend on this entry
  cache.invalidate(entry);

  return Response.json({ ok: true });
};

Links

@ascorbic ascorbic mentioned this pull request Oct 15, 2025
Added cache configuration for routes in astro.config.ts and updated cache invalidation tag in webhook API.
@wildfiremedia
Copy link

I’d like to see a summary of the total cache along with a list of tags, so we can get an idea of what’s being cached or invalidated and catch any tags that might have been missed which allows us to easily debug.

@florian-lefebvre
Copy link
Member

I'm not sure that's something for Astro to provide, feels like maybe it should be achievable through the driver platform (eg. cloudflare)?

@wildfiremedia
Copy link

wildfiremedia commented Oct 21, 2025

Exclusively for self-hosted servers, these metrics (memory, SWR settings, etc.) are provided in JSON format. I think they could be useful for storefronts that need to cache product listings.

Other ideas, can it cache payload in gzipped or brotli? Thinking by 50% or more is worth saving and reduce latency.

@stipsan
Copy link

stipsan commented Nov 14, 2025

From the perspective of Sanity I think we'd like to automatically define cache tags since our backend provides them, regardless of how customers define their GROQ queries and how the data is fetched, it's fairly dynamic.
But we wouldn't want to define maxAge or swr automatically.

For us if we can define a live loader that could call Astro.cacheTag(...tags: string[]) and have it hoist up/merge with the userland Astro.cache() call that would be ideal.
Since we allow resolving references, which is similar to a db join, even getting a single document entry might have linked data that have multiple cache tag.

A blog post could be resolving an author reference. If the author reference is edited it should invalidate all post entries that use it.
For that to work without too much wiring we'd need to define all the cache tags that can be used for invalidation on both the get entry and on the collection levels.

Since customers often fetch data from other sources than ours we can't make any assumptions on their maxAge and swr ideal settings.

For next.js we're able to do this by calling cacheTag, and changing the revalidate setting on cacheLife. Userland can override these settings by having the callsite use shorter revaldiate times, as well as different settings for stale and expire. I'd like for Astro users to have the same, simple, API surface.

@ascorbic
Copy link
Contributor Author

ascorbic commented Feb 11, 2026

I've been going through the RFC and made some changes to clarify things, tidy things up etc.

Astro.cache as an object with methods

Changed from Astro.cache() (callable) to Astro.cache.set(). This is more consistent with other Astro global (Astro.cookies.set(), Astro.session.set()) and allows us to add invalidate without an unusual callable-with-methods pattern. The full AstroCache interface:

interface AstroCache {
  set(options: CacheOptions | CacheHint | LiveDataEntry | false): void;
  readonly tags: string[];
  invalidate(options: InvalidateOptions | LiveDataEntry): Promise<void>;
}

Available as Astro.cache in pages and context.cache in API routes/middleware.

Merge semantics for multiple cache.set() calls

Calling cache.set() multiple times merges rather than replaces:

  • maxAge, swr, etag: last-write-wins
  • lastModified: most recent date wins
  • tags: accumulated (union, deduplicated)
  • false: overrides everything, disables caching

This matches the existing semantics for live collecitons, and makes it natural to compose cache hints from multiple data sources (e.g. a page that depends on both a product and an author entry). @stipsan's Sanity use case would usually do this via the live collections API, which allows tags to be returned as part of the loader response.

cache.set(false) for opting out

If a route matches a config-level cache rule but shouldn't be cached, Astro.cache.set(false) explicitly disables caching.

Unified invalidation on Astro.cache

Per @florian-lefebvre's feedback, invalidation now lives on the same object: Astro.cache.invalidate() in pages, context.cache.invalidate() in API routes/middleware.

InvalidateOptions simplified

Removed the tag/tags duality. Now just tags: string | string[].

API routes can set cache, not just invalidate

Added examples showing cache.set() in API routes and middleware, not just cache.invalidate().

Config route precedence clarified

Explicitly documented: no merging between config patterns, most specific wins, each pattern must be fully specified. Priority order:

  1. cache.set(false) in route (disables)
  2. cache.set() in route or middleware
  3. Most specific config pattern
  4. Less specific config patterns
  5. No caching (default)

No caching in dev

Explicitly documented that caching is not active during development. The API is available but calls are no-ops.

Header stripping clarified

For header-based providers, the CDN strips CDN-Cache-Control per RFC 9213. For runtime providers, the framework strips CDN-Cache-Control and Cache-Tag from the outgoing response.

export default defineConfig({
adapter: node(),
cache: {
routes: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me think about https://nitro.build/config#routerules in nitro. So I'm wondering: is there any benefit to have this under cache? Instead that could be on the top level and we could also set prerender: boolean there.

So to recap, this looks good to me as is but maybe there's a way to future proof it and enable more cool things

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, yeah. Good idea. routeRules would be a better name in that scneario too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we just directly copy the Nitro pattern, with "swr: true|number is shortcut for cache: { swr: true, maxAge: number }"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A recurring problem with supporting different shapes it doesn't work very nicely with updateConfig(). Otherwise no strong opinion


Caching is essential for performant server-rendered applications, but current solutions have significant limitations:

**Platform-specific implementations**: Most caching solutions (like ISR) are tightly coupled to specific hosting platforms. ISR was created for Next.js on Vercel and while other platforms have implemented versions of it, they're often limited or second-class implementations. This creates vendor lock-in and inconsistent behavior across deployments.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, or leave it deprecated and make it conflice with the new caching

- **Distributed caching for Node.js**: Node adapter uses in-memory cache, unsuitable for multi-instance deployments without external cache
- **Static/prerendered page caching**: This is for on-demand SSR routes only; static and prerendered routes are already cached by default and don't need route-level cache control
- **Partial page caching**: This focuses on full-page route caching, not fragment or component-level caching
- **Browser caching**: This focuses on CDN and server-side caching, not browser caching. Only `Last-Modified` and `ETag` headers are sent to browsers for conditional requests
Copy link
Member

@florian-lefebvre florian-lefebvre Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can users access the current state of the cache (eg. after several cache.set() calls) so they can use this data to add browser caching headers? So not only Astro.cache.tags


```ts
interface CacheProvider {
name: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably more useful to have the name in the cache.provider than the runtime implementation, so it can be used in logging etc

@ascorbic
Copy link
Contributor Author

@florian-lefebvre I have done an update that among other things moves the rules from cache.routes to routeRules

Memory cache moved to core. Config function is now memoryCache()
from astro/config instead of cacheMemory() from @astrojs/node/cache.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants