fix(cache): admit metadata routes to the CDN cache on their own Cache-Control - #3449
Conversation
…-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
|
/bigbonk review for issues |
|
LGTM! |
|
/bigbonk review for issues |
@vinext/cloudflare
create-vinext-app
@vinext/types
vinext
@cloudflare/workers-response-store
commit: |
Performance benchmarksCompared 0 improved · 1 regressed · 5 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
|
LGTM! |
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 asno-storeand re-rendered on every request. This affected routes likeopengraph-image,twitter-image,icon,apple-icon,sitemap,robotsandmanifest.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-Atso 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 intests/metadata-route-response.test.ts, illustrating the following behaviour changes:opengraph-imageon its ownCache-Control(GET, HEAD)no-store,private, static or serialized routes, or a"use cache"replay as explicit (5 tests)no-store,Set-Cookie, aCookieorAuthorizationrequest header, 5xx, POST, or the framework default (7 tests)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
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.Cookie,AuthorizationorProxy-Authorization, if it isn't a GET or HEAD, or if the response is a 5xx, sets a cookie, saysno-store,privateorno-cache, or uses aVarythe CDN can't key on.private, max-age=0, must-revalidateon an edge-cached response, the same as on every other edge-cached route; the edge keeps the route's own policy.Implementation Details
handleMetadataRouteRequestmatches a metadata route, it registers the route asapp-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.:paramform app routes use, for example/event/:city/:eventId/opengraph-image, not the concrete URL.Responsecarries a cacheable CDN policy, it is recorded withmarkRouteCacheabilityExplicitResponsePolicy(). Route handlers outside the probed manifest are admitted the same way.Note: A dedicated
app-metadatakind 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
GETroute 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
"use cache". fix(prerender): static metadata route should be optimized by default #3004 doesn't touch the Workers response stage or CDN admission, so it would not help a dynamic per-param image at the edge. The two changes complement each other."use cache"serialization errors exits with code 0 #2949, closed by fix(cache): reject unserializable use cache results #2954:"use cache"around anImageResponsenow rejects. Together with this bug, that left no supported way to edge-cache a dynamic metadata image."use cache"metadata routes, the path Generated metadata images are not statically optimized by default #2950 wants to generalize.