Skip to content

Comments

feat: route caching#15579

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

feat: route caching#15579
ascorbic wants to merge 25 commits intomainfrom
feat/route-caching

Conversation

@ascorbic
Copy link
Contributor

@ascorbic ascorbic commented Feb 19, 2026

Changes

Adds a new experimental Route Caching API and Route Rules for controlling SSR response caching. See the RFC for full details.

Route caching gives you a platform-agnostic way to cache server-rendered responses, based on web standard cache headers. You set caching directives in your routes using Astro.cache (in .astro pages) or context.cache (in API routes and middleware), and Astro translates them into the appropriate headers or runtime behavior depending on your adapter. You can also define cache rules for routes declaratively in your config using experimental.routeRules, without modifying route code.

Getting started

Enable the feature by configuring experimental.cache with a cache provider in your Astro config:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import { memoryCache } from 'astro/config';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
  experimental: {
    cache: {
      provider: memoryCache(),
    },
  },
});

Using Astro.cache and context.cache

In .astro pages, use Astro.cache.set() to control caching:

---
// src/pages/index.astro
Astro.cache.set({
  maxAge: 120,       // Cache for 2 minutes
  swr: 60,           // Serve stale for 1 minute while revalidating
  tags: ['home'],    // Tag for targeted invalidation
});
---
<html><body>Cached page</body></html>

In API routes and middleware, use context.cache:

// src/pages/api/data.ts
export function GET(context) {
  context.cache.set({
    maxAge: 300,
    tags: ['api', 'data'],
  });
  return Response.json({ ok: true });
}

Cache options

cache.set() accepts the following options:

  • maxAge (number): Time in seconds the response is considered fresh.
  • swr (number): Stale-while-revalidate window in seconds. During this window, stale content is served while a fresh response is generated in the background.
  • tags (string[]): Cache tags for targeted invalidation. Tags accumulate across multiple set() calls within a request.
  • lastModified (Date): When multiple set() calls provide lastModified, the most recent date wins.
  • etag (string): Entity tag for conditional requests.

Call cache.set(false) to explicitly opt out of caching for a request.

Multiple calls to cache.set() within a single request are merged: scalar values use last-write-wins, lastModified uses most-recent-wins, and tags accumulate.

Invalidation

Purge cached entries by tag or path using cache.invalidate():

// Invalidate all entries tagged 'data'
await context.cache.invalidate({ tags: ['data'] });

// Invalidate a specific path
await context.cache.invalidate({ path: '/api/data' });

Config-level route rules

Use experimental.routeRules to set default cache options for routes without modifying route code. Supports Nitro-style shortcuts for ergonomic configuration:

import { memoryCache } from 'astro/config';

export default defineConfig({
  experimental: {
    cache: {
      provider: memoryCache(),
    },
    routeRules: {
      // Shortcut form (Nitro-style)
      '/api/*': { swr: 600 },

      // Full form with nested cache
      '/products/*': { cache: { maxAge: 3600, tags: ['products'] } },
    },
  },
});

Route patterns support static paths, dynamic parameters ([slug]), and rest parameters ([...path]). Per-route cache.set() calls merge with (and can override) the config-level defaults.

You can also read the current cache state via cache.options:

const { maxAge, swr, tags } = context.cache.options;

Cache providers

Cache behavior is determined by the configured cache provider. There are two types:

  • CDN providers set response headers (e.g. CDN-Cache-Control, Cache-Tag) and let the CDN handle caching. Astro strips these headers before sending the response to the client.
  • Runtime providers implement onRequest() to intercept and cache responses in-process, adding an X-Astro-Cache header (HIT/MISS/STALE) for observability.

Built-in memory cache provider

Astro includes a built-in in-memory LRU cache provider. Import memoryCache from astro/config to configure it.

Features:

  • In-memory LRU cache with configurable max entries (default: 1000)
  • Stale-while-revalidate support
  • Tag-based and path-based invalidation
  • X-Astro-Cache response header: HIT, MISS, or STALE

Writing a custom cache provider

A cache provider is a module that exports a factory function as its default export:

import type { CacheProviderFactory } from 'astro';

const factory: CacheProviderFactory = (config) => {
  return {
    name: 'my-cache-provider',
    // For CDN-style: set response headers
    setHeaders(options) {
      const headers = new Headers();
      if (options.maxAge !== undefined) {
        headers.set('CDN-Cache-Control', `max-age=${options.maxAge}`);
      }
      return headers;
    },
    // For runtime-style: intercept requests (optional)
    async onRequest(context, next) {
      // ... check cache, call next(), store response
    },
    // Handle invalidation
    async invalidate(options) {
      // ... purge by tags or path
    },
  };
};

export default factory;

Error handling

If you use Astro.cache or context.cache without enabling the feature, Astro throws an AstroError with the name CacheNotEnabled and a message explaining how to configure it. If the configured provider cannot be resolved, Astro throws CacheProviderNotFound at build time.

Testing

Adds comprehensive test suite

Docs

withastro/docs#13305

@changeset-bot
Copy link

changeset-bot bot commented Feb 19, 2026

🦋 Changeset detected

Latest commit: 17e1b18

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added pkg: integration Related to any renderer integration (scope) pkg: astro Related to the core `astro` package (scope) docs pr labels Feb 19, 2026
…time

Adds the foundational building blocks for the route caching feature (RFC #1245):

- CacheOptions, CacheProvider, CacheProviderFactory, InvalidateOptions types
- Zod config schema (CacheSchema) registered under experimental.cache
- AstroCache class with set() merge semantics, tags accumulation, invalidate(), _applyHeaders()
- NoopAstroCache for dev mode
- Utility functions: normalizeCacheDriverConfig, cacheConfigToManifest, defaultSetHeaders
- Public type exports from astro package
Integrates the cache system into the full request lifecycle:

- Virtual module vite-plugin for cache driver resolution (resolves from project root)
- SSRManifest: cacheDriver and cacheConfig fields
- BasePipeline: getCacheProvider() with lazy resolution
- RenderContext: creates AstroCache/NoopAstroCache per request, exposes on
  Astro.cache and context.cache (API routes, actions, middleware)
- App base: wraps onRequest for runtime providers, strips CDN headers after;
  applies _applyHeaders for CDN-based providers
- Serialized manifest: cache driver import + config serialization
- Throwing getter on Astro global for prerendered routes
Compiles cache.routes patterns using Astro's existing route parsing
infrastructure (getParts, getPattern, routeComparator). Patterns use
the same [param]/[...rest] syntax as file-based routing. Most specific
route wins, computed once at startup via compileCacheRoutes().
72 unit tests covering:
- AstroCache runtime: set() merge semantics, tags, invalidate, _applyHeaders, _isActive
- Utils: defaultSetHeaders, normalizeCacheDriverConfig, cacheConfigToManifest, isCacheHint, isLiveDataEntry
- NoopAstroCache: all methods callable and no-op
- Route matching: exact paths, dynamic params, rest params, priority ordering

6 integration tests with mock CDN provider:
- CDN-Cache-Control and Cache-Tag from context.cache.set()
- cache.set(false) opt-out, tags-only, config-level routes
- .astro page and API route support
Runtime cache provider for @astrojs/node with:
- In-memory LRU cache (custom Map-based, no external deps)
- SWR support via stale-while-revalidate with background revalidation
- Tag-based and path-based invalidation
- X-Astro-Cache response header (HIT/MISS/STALE) for observability
- Auto-set as default when experimental.cache is enabled without a driver
- Exported as @astrojs/node/cache subpath
8 tests covering the full runtime caching lifecycle:
- Default driver auto-configuration
- Cache hit/miss behavior with body verification
- CDN header stripping for runtime providers
- Tag-based and path-based invalidation
- cache.set(false) opt-out
- Uncached route passthrough
Full phased implementation plan for RFC #1245 covering:
- Phases 1-3: Core types, pipeline wiring, route matching (complete)
- Phase 4: Node adapter with in-memory LRU (complete)
- Phase 5: Cloudflare adapter with CacheW
- Phase 6: Vercel & Netlify adapters
- Phase 7: Documentation
Replace _applyHeaders() and _isActive underscore convention with
symbol-keyed methods behind applyCacheHeaders() and isCacheActive()
helper functions. The symbols are not exported from the astro package,
so users can't access them. Framework call sites import the helpers
from the module directly. NoopAstroCache no longer needs the internal
methods — the helpers handle the no-op case via 'in' checks.
Breaking: cache.routes moved to experimental.routeRules

This commit addresses RFC #1245 feedback:

1. Terminology: Rename 'driver' to 'provider' throughout the cache API.
   Providers have a richer interface (headers, middleware, invalidation)
   than the simpler driver pattern used by sessions/unstorage.

2. Configuration: Move cache route config from cache.routes to
   experimental.routeRules with Nitro-style shortcuts:
   - Flat cache options: { maxAge: 600, swr: 60 }
   - Nested form: { cache: { maxAge: 600 } }
   - Prerender control: { prerender: true }

3. API: Add cache.options getter for full cache state access,
   complementing the existing cache.tags getter.

Files renamed:
- cache-driver.ts → cache-provider.ts (both astro + node adapter)
- mock-cache-driver.mjs → mock-cache-provider.mjs (test fixture)

All 109 tests pass (95 unit + 7 core + 7 node adapter).
routeRules is focused on cache configuration only. Prerender control
will be handled separately if/when it's added to the feature.
@codspeed-hq
Copy link

codspeed-hq bot commented Feb 19, 2026

Merging this PR will not alter performance

✅ 18 untouched benchmarks


Comparing feat/route-caching (d9b6b83) with main (35bc814)

Open in CodSpeed

ascorbic and others added 10 commits February 19, 2026 16:19
…om SSR bundle

- Move runtime cache code (AstroCache, noop, route-matching, utils) into
  cache/runtime/ directory following Astro conventions
- Remove node:url and node:path imports that leaked into SSR bundle,
  breaking Cloudflare/edge deployments and verify-no-node-stuff test plugin
- Guard getCacheProvider() call in hot path to skip async overhead when
  no cache provider is configured (addresses CodSpeed benchmark regression)
- Move in-memory LRU cache provider from @astrojs/node to astro core
  (it's runtime-agnostic, not Node-specific)
- Export memoryCache() from astro/config (follows sharpImageService pattern)
- Runtime entrypoint at astro/cache/memory (separate from config context)
- Remove bare string form for cache provider config (object-only)
- Export CacheProviderConfig from public types
The memory cache provider is now in core, so its tests belong here
too. Uses testAdapter() + app.render() instead of a live Node server.
# Conflicts:
#	packages/astro/src/core/app/base.ts
@ascorbic ascorbic marked this pull request as ready for review February 23, 2026 16:01
Copy link
Member

@sarah11918 sarah11918 left a comment

Choose a reason for hiding this comment

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

I'll let @yanthomasdev tackle the commas, but just some thoughts from me re: error docs and changeset!

I'll note the changeset is... huge! I do not object, nor do I object to offloading some of that heavy lifting to the docs themselves, if you decide you want to just give more superficial overviews of the various parts of the feature.

'astro': minor
---

Adds a new experimental Route Caching API and Route Rules for controlling SSR response caching. See the [RFC](https://github.com/withastro/roadmap/pull/1245) for full details.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Adds a new experimental Route Caching API and Route Rules for controlling SSR response caching. See the [RFC](https://github.com/withastro/roadmap/pull/1245) for full details.
Adds a new experimental Route Caching API and Route Rules for controlling SSR response caching

Suggest removing this link since it's also at the bottom and the shorter the first paragraph is for a large PR, the easier it is to read (with all the other stuff like the author attribution)


/**
* @docs
* @kind heading
Copy link
Member

Choose a reason for hiding this comment

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

Just noting that this leaves one errant SVG error at the end, also within this heading.

(Would argue it's that one that's in the wrong place, but if we don't fix it as out of scope in this PR, would be nice to get that error moved somewhere more sensible!

title: 'Cache is not enabled.',
message:
'`Astro.cache` is not available because the cache feature is not enabled. To use caching, configure a cache provider in your Astro config under `experimental.cache`.',
hint: 'Use an adapter that provides a default cache provider, or set one explicitly: `experimental: { cache: { provider: "..." } }`.',
Copy link
Member

Choose a reason for hiding this comment

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

Could also be a good place to link to the experimental docs since someone who doesn't have this configured is going to need more than ... to know what to put for a provider.

If you use `Astro.cache` or `context.cache` without enabling the feature, Astro throws an `AstroError` with the name `CacheNotEnabled` and a message explaining how to configure it. If the configured provider cannot be resolved, Astro throws `CacheProviderNotFound` at build time.

For more information on enabling and using this feature in your project, see the [Experimental Route Caching docs](https://docs.astro.build/en/reference/experimental-flags/route-caching/).
For a complete overview, and to give feedback on this experimental API, see the [Route Caching RFC](https://github.com/withastro/roadmap/pull/1245).
Copy link
Member

Choose a reason for hiding this comment

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

comma nit

Suggested change
For a complete overview, and to give feedback on this experimental API, see the [Route Caching RFC](https://github.com/withastro/roadmap/pull/1245).
For a complete overview and to give feedback on this experimental API, see the [Route Caching RFC](https://github.com/withastro/roadmap/pull/1245).

*/
const RouteRuleSchema = z.object({
// Nested cache options (full form)
cache: CacheOptionsSchema.optional(),
Copy link
Member

Choose a reason for hiding this comment

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

I think it may make sense to not have cache and just keep the flat shortcuts. If you want to keep both, you'll probably need to use a union instead to not allow both at the same time

const cache = new LRUMap<string, CachedEntry>(max);

return {
name: 'memory',
Copy link
Member

Choose a reason for hiding this comment

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

I did left a comment on the RFC about this but reiterating: I don't think it's useful to have name defined in the implementation. I think it'd be better in the config object alongside entrypoint instead so we can use it for logging in the build, eg. if we can't load the provider. One may argue that maybe we don't need a name at all and entrypoint is enough

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason I had it is for logging etc, so it's clear which provider it is. We're not using it at the moment, but I can see a time when we'd log that. We have the same pattern for content loaders.

Copy link
Member

Choose a reason for hiding this comment

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

Np with keeping the name 👍 but my point on where to provider this name still stands

}

const memoryProvider: CacheProviderFactory = (
config: Record<string, any> | undefined,
Copy link
Member

Choose a reason for hiding this comment

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

Combined with satisfies CacheProviderFactory

Suggested change
config: Record<string, any> | undefined,
options: MemoryCacheProviderOptions = {},

vitePluginActions({ fs, settings }),
vitePluginServerIslands({ settings, logger }),
vitePluginSessionDriver({ settings }),
...(settings.config.experimental?.cache ? [vitePluginCacheProvider({ settings })] : []),
Copy link
Member

Choose a reason for hiding this comment

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

knit: I think it could be better to have the plugin return undefined so all the logic is confined there

public partial: undefined | boolean = undefined,
public shouldInjectCspMetaTags = pipeline.manifest.shouldInjectCspMetaTags,
public session: AstroSession | undefined = undefined,
public cache: AstroCache | NoopAstroCache | DisabledAstroCache = disabledAstroCache,
Copy link
Member

Choose a reason for hiding this comment

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

Is there a good reasons to have different types VS just AstroCache?

* });
* ```
*/
provider?: import('../../core/cache/types.js').CacheProviderConfig;
Copy link
Member

Choose a reason for hiding this comment

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

For consistency, should be a regular type import

* }
* ```
*/
routeRules?: Record<
Copy link
Member

Choose a reason for hiding this comment

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

If you want, you could instead export a RouteRules type from cache/types.js to keep things collocated. Other advantage is that you can then write a type test to make sure the type and the schema remain in sync

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs pr pkg: astro Related to the core `astro` package (scope) pkg: integration Related to any renderer integration (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants