Skip to content

fix(cache): admit metadata routes to the CDN cache on their own Cache-Control - #3449

Merged
james-elicx merged 4 commits into
cloudflare:mainfrom
mhsnook:fix/metadata-route-cdn-caching
Sep 25, 2026
Merged

james-elicx merged 4 commits into
cloudflare:mainfrom
mhsnook:fix/metadata-route-cdn-caching

Conversation

@mhsnook

@mhsnook mhsnook commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

What was broken

On CF Workers, App Router metadata routes weren't reaching the CDN cache. A dynamic route that returns an image might try to return a Response with public, immutable, max-age=31536000, but it was still served as no-store and re-rendered on every request. This affected routes like opengraph-image, twitter-image, icon, apple-icon, sitemap, robots and manifest.

This likely dates from #3092 and is present in 1.0.0-beta.10 through beta.12 and main.

Two minimal reproduction apps (source here):

Each route sends X-Rendered-At so you can see if the response was cached. You'll notice the NextJS site caches the opengraph image after the first visit; the vinext one does not.

What the fix fixes

All changes are in packages/vinext/src/server/metadata-route-response.ts, with tests in tests/metadata-route-response.test.ts, illustrating the following behaviour changes:

Test Before After
Admits a dynamic opengraph-image on its own Cache-Control (GET, HEAD) ❌ fail ✅ pass
Registers the route pattern, not the concrete path ❌ fail ✅ pass
Registers generated sitemaps under their base route ❌ fail ✅ pass
Records the route's own public header as an explicit policy ❌ fail ✅ pass
Does not record the framework default, no-store, private, static or serialized routes, or a "use cache" replay as explicit (5 tests) ❌ fail ✅ pass
Keeps the route private for no-store, Set-Cookie, a Cookie or Authorization request header, 5xx, POST, or the framework default (7 tests) ✅ pass ✅ pass
Does not register a request that matches no metadata route ✅ pass ✅ pass

Five explicit-policy tests also assert that the route registered, which is why they fail before the change. The eight tests that pass both ways guard against over-admission (the old code never admitted a metadata route, so they only catch a regression in the new path).

Still true

  • A metadata route only enters the edge cache when it sets its own cacheable Cache-Control. Routes that rely on the framework default (public, max-age=0, must-revalidate) stay out of it, including static metadata files and generated sitemap, robots and manifest output.
  • "use cache" metadata routes are still served from vinext's ISR cache (isrGet/isrSet). A replayed ISR entry does not count as an opt-in to the edge cache.
  • A response still stays out of the shared cache if the request carries Cookie, Authorization or Proxy-Authorization, if it isn't a GET or HEAD, or if the response is a 5xx, sets a cookie, says no-store, private or no-cache, or uses a Vary the CDN can't key on.
  • Clients still get private, max-age=0, must-revalidate on an edge-cached response, the same as on every other edge-cached route; the edge keeps the route's own policy.

Implementation Details

  • When handleMetadataRouteRequest matches a metadata route, it registers the route as app-route. Generated sitemap children register under their base route. Every runtime (dev, Node prod, Workers with or without a response stage) goes through this function.
  • The registered identity is the route pattern in the same :param form app routes use, for example /event/:city/:eventId/opengraph-image, not the concrete URL.
  • When the route's own Response carries a cacheable CDN policy, it is recorded with markRouteCacheabilityExplicitResponsePolicy(). Route handlers outside the probed manifest are admitted the same way.

Note: A dedicated app-metadata kind is an option if maintainers prefer to keep metadata routes separate in the manifest and probe payloads.

Comparing to NextJS

Next.js compiles every metadata file convention into a GET route handler (next-metadata-route-loader.ts). Its docs describe them as "special Route Handlers that are cached by default unless [they use] a Request-time API or dynamic config option": opengraph-image, app-icons, sitemap, robots, manifest.

Related Issues

claude and others added 2 commits September 24, 2026 11:05
…-Control

On Workers, a response enters the shared cache only after its route has
registered a cacheability kind with beginRouteCacheability(). App pages,
route handlers, Pages pages and Pages API routes all register. Metadata
routes (opengraph-image, twitter-image, icon, apple-icon, sitemap,
robots, manifest) never did, so admission treated every one of them as
unclassified and answered no-store, whatever Cache-Control the route
returned. A dynamic opengraph-image returning an ImageResponse with
`public, immutable, max-age=31536000` was re-rendered on every request.

Next.js compiles each metadata file convention into a GET route handler
and documents them as "special Route Handlers that are cached by
default", so metadata routes now register as `app-route`. Registration
happens in handleMetadataRouteRequest where the route is matched, so the
identity is the route pattern (`/event/:city/:eventId/opengraph-image`)
in the same `:param` form app routes use, rather than the concrete URL.
Every runtime (dev, Node prod, Workers with and without a response
stage) goes through that function.

When the route's own Response carries a cacheable CDN policy, it is
recorded as an explicit response policy, which is how route handlers
are admitted outside the probed manifest. The framework's default
`public, max-age=0, must-revalidate`, serialized sitemap/robots/manifest
bodies, static metadata files and "use cache" ISR replays are not
treated as an opt-in, so their admission is unchanged. The existing
vetoes still apply: credentialed requests, non-read methods, 5xx,
Set-Cookie, no-store/private and unsupported Vary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ5HUxNSfTm8u5X4PbdPK3
@mhsnook mhsnook changed the title fix(cache): admit metadata routes to the CDN cache on their own Cache-Control fix(cache): can't cache metadata routes like dynamic og:image Sep 25, 2026
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues
Please review exact head 8a9123e0173aebaabdae3617d20f1fb8a4535792 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues
Please review exact head 6466d46971833d8c32dd6a3f286f55fdbec6e655 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@james-elicx james-elicx changed the title fix(cache): can't cache metadata routes like dynamic og:image fix(cache): admit metadata routes to the CDN cache on their own Cache-Control Sep 25, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3449

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@3449

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3449

vinext

npm i https://pkg.pr.new/vinext@3449

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3449

commit: 6466d46

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 6466d46 against base 57d9efa using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.3 KB 142.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 129.7 KB 130.4 KB ⚫ +0.6%
Dev server cold start vinext 2.17 s 2.23 s 🔴 +2.4%
Production build time vinext 2.26 s 2.23 s ⚫ -1.2%
RSC entry closure size (gzip) vinext 128.3 KB 128.2 KB ⚫ -0.0%
Server bundle size (gzip) vinext 226.6 KB 226.8 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues
Please review exact head 6466d46971833d8c32dd6a3f286f55fdbec6e655 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx merged commit c4b705e into cloudflare:main Sep 25, 2026
66 checks passed
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.

3 participants