fix(cloudflare): share one Workers Cache entry across hosts by james-elicx · Pull Request #3924 · cloudflare/vinext · GitHub
Skip to content

fix(cloudflare): share one Workers Cache entry across hosts - #3924

Open
james-elicx wants to merge 5 commits into
fix/response-store-pages-host-free-keyfrom
fix/workers-cache-host-free-key
Open

james-elicx wants to merge 5 commits into
fix/response-store-pages-host-free-keyfrom
fix/workers-cache-host-free-key

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Next.js keys its response cache by pathname, so every host an app serves shares one entry. Cloudflare Workers Cache keys an entry by its path and query, entrypoint, ctx.props and Worker version, not by host. But vinext put the request's full URL, host included, into its __vinext_cache_key digest and into the ctx.props it passed the cached entrypoint. So every host filled and stored its own copy of each page.

The Workers Cache adapter now declares responseStageCacheIdentity: "host-free". Core gives it the same host-free identity the Response Store gets (the canonical cache identity origin, or a Pages page's configured i18n domain), but keeps every dispatch's query. Workers Cache keys the dispatched query itself, and the render needs it, so the App page query-free identity stays Response Store only. The manifest-based query-free dispatch for static-candidate App pages is unchanged.

On a shared dispatch with an identity:

  • The gateway puts the identity's props and URL into the ctx.props and the digest it keys by.
  • The host-bearing props (requestOrigin, requestUrl, requestHost) and the real request URL go in an internal header that Workers Cache doesn't key. The gateway strips that header from public requests.
  • The cached entrypoint restores those fields before rendering, so a cold render or the edge's background refresh sees the real host.
  • An identity invocation without a valid restore, or one whose restore names another path or query, fails closed with a 400 no-store.
  • If an identity differs from its dispatch in anything but those host fields, the dispatch keys itself as before.

Cloudflare also keys a few headers that name another host, such as X-Forwarded-Host. A proxy in front of the Worker that sets one still gets an entry per forwarded host; the docs say so.

Workers Cache warms what its cacheability probe finds cacheable. A probe dispatch bypasses the shared transport, so it never marked a Pages page as shared across hosts, and the render-time check that refuses a shared entry for a page whose getInitialProps an imported wrapper attaches only ran during admission. Warming then waited forever for an entry that page's shared dispatch never stores. A probe now marks the page shared across hosts, as its shared dispatch is, and that check marks the render dynamic (Next.js doesn't treat a getInitialProps page as static), in probes and admission alike.

The same admission rules as the Response Store apply. A render that reads the host is dynamic and isn't stored. Per-request Pages responses (getServerSideProps, API routes, pages whose getInitialProps runs) keep the host in their keys under an explicit CDN policy, as a CDN in front of Next.js would. docs/caching.mdx now describes Workers Cache together with the Response Store.

Tests

  • Unit (tests/cloudflare-cdn-worker.test.ts):
    • An App and a Pages host-free identity get one ctx.props, digest and path for two hosts, and each render sees its own host and URL.
    • An identity that names another path or query, or drops a prop other than the host, isn't used.
    • The entrypoint refuses a missing, malformed, other-page or non-host restore.
    • The gateway strips a forged restore header.
    • These fail on the pre-fix worker.
  • Request stages:
    • tests/app-rsc-handler.test.ts: a host-free App page identity keeps the query and selector headers.
    • tests/pages-request-worker-stage.test.ts: the static Pages identity test now runs for both identity kinds.
    • Probes of a page shared across hosts, Pages-only and hybrid, carry sharedAcrossHosts without a cache identity. The hybrid bridge marks such a getInitialProps render dynamic in admit and probe modes.
  • Deployed e2e (tests/e2e/cloudflare-workers/cache-prewarm.spec.ts, workers-cache job):
    • The PR's preview alias (pr-<n>-workers-cache) and its Version URL are two hosts of one uploaded version. A new demo route, /api/worker-version, reports the version id.
    • An App page, a getStaticProps page and an ISR route handler filled on the alias host are a HIT, with the same render, on the Version URL host.
    • The test doesn't compare a Version URL with production traffic. On the deployed prewarm Worker, a Version URL never reached the entries that production requests filled. Cloudflare's docs don't say whether those share a cache.
    • A getInitialProps page that echoes the host always shows each host its own.
  • Local Response Store e2e still passes (55/55). Local wrangler dev doesn't emulate Workers Cache, so the deployed job is the e2e proof.

@james-elicx

Copy link
Copy Markdown
Member Author

@james-elicx
james-elicx added this pull request to stack #3899 October 13, 2026 10:54
@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

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

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: 19c6a47

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 6877554 against base d999565 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 7 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 151.2 KB 151.2 KB ⚫ 0.0%
Client entry size (gzip) vinext 140.8 KB 140.8 KB ⚫ 0.0%
Dev server cold start vinext 2.93 s 2.90 s ⚫ -0.9%
Dev server cold start (large Pages Router app) vinext 26.84 s 27.08 s ⚫ +0.9%
Production build time vinext 3.78 s 3.82 s ⚫ +1.1%
RSC entry closure size (gzip) vinext 142.9 KB 142.9 KB ⚫ +0.0%
Server bundle size (gzip) vinext 247.9 KB 247.9 KB ⚫ +0.0%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx force-pushed the fix/workers-cache-host-free-key branch from a482fef to f223913 Compare October 13, 2026 11:13
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk please review this PR at head e407c0d303c3b52d6282ec50456bcece9820ed3a. You have an 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk please review this PR at head d6aed61933497f0964fef85b9dea05ebc50c7ed6. You have an 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

@james-elicx
james-elicx marked this pull request as ready for review October 13, 2026 12:36
@james-elicx
james-elicx force-pushed the fix/workers-cache-host-free-key branch from d6aed61 to 6877554 Compare October 20, 2026 23:09
@james-elicx

Copy link
Copy Markdown
Member Author

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

@clanky-the-clanker

clanky-the-clanker Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

💬 Approved with comments — The host-free Workers Cache identity, the restore header and its fail-closed checks, and the probe and dynamic-marking changes are correct. The only finding is an optional suggestion to document the new identity contract in AGENTS.md.

Findings: 1 suggestion

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #3924.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## SUGGESTION (1)

### [AGENTS.md] Consider adding a short note or a link to docs/caching.mdx in AGENTS.md about the host-free response-stage cache identity and the host restore through the internal dispatch header.

📖 AGENTS.md: 💬 1 suggestion

The new response-stage identity contract isn't mentioned in the agent guidance.

Recommendation: 💬 Optional Nits

  • [SUGGESTION] Consider adding a short note or a link to docs/caching.mdx in AGENTS.md about the host-free response-stage cache identity and the host restore through the internal dispatch header.
View full review
  • The ISR Architecture section of AGENTS.md covers the cache adapters, but not the new responseStageCacheIdentity contract (host-free vs query-free) or how the Workers Cache transport restores host fields. A short note or a link to docs/caching.mdx would help later changes keep the host-free invariants: renders that read the host must be dynamic, and the restore header must stay internal.

✅ All other checks passed

🔒 Security: ✅ LGTM

No issues found.

  • Restore header. x-vinext-internal-dispatch is removed from public requests in stripUntrustedTransportHeaders (lines 154-170). It is also removed from the cache-facing request and from the restored render request.
  • Identity invocations. An identity invocation without a valid restore is refused with a 400 no-store. A valid restore can only change host-bearing props, and its path and query must match the keyed entry. A forged header can't point an entry at another page.
  • Cache key. The digest still includes Authorization, the Vary selector headers and the build identity.
  • Remaining exposure. A render that reads the host but isn't marked dynamic would be stored and served to other hosts. The Response Store already depends on the same admission rules, and this pull request closes the known getInitialProps case by marking those renders dynamic.

📋 Code Quality: ✅ LGTM

No issues found. This review read the code; it did not run any tests.

Worker identity and restore (packages/cloudflare/src/cache/cdn-adapter.worker.ts)

  • getDispatchRestore returns null, so the dispatch keys itself as before, when the identity differs from the dispatch in method, path, query, or any prop other than requestHost, requestOrigin and requestUrl.
  • restoreDispatchInvocation (lines 689-716) checks the restore header again before using it:
    • the header must parse as JSON;
    • it may only set host-bearing props;
    • its URL must have the same path and query as the keyed URL.
      If any check fails, the request is refused with a 400 no-store.

markRouteCacheabilityDynamic

  • It replaces the final-response veto and now applies in probe mode as well as admission. This fixes the prewarm hang for Pages pages whose getInitialProps comes from an imported wrapper.
  • A forced-dynamic outcome doesn't set dynamicUsage, so it can't trigger the "static to dynamic" error for manifest routes.

App identity (app-rsc-handler.ts)

  • The query-free identity is still limited to adapters that declare query-free. A host-free adapter keeps the query and the selector headers, and the new test covers this.

Probes

  • Probes set sharedAcrossHosts but pass cacheIdentity only for shared dispatches. The Pages-only and hybrid tests check this.

E2E

  • The preview alias and Version URL host derivation matches the alias format.
  • The test skips when the alias env vars are missing.

⚡ Performance: ✅ LGTM

No performance concerns. Each request now builds one more JSON header and checks it once. Sharing one cache entry across hosts should cut the number of renders and stored copies.


📝 Documentation: ✅ LGTM

The docs/caching.mdx update describes Workers Cache together with the Response Store. It also notes that headers naming another host, such as X-Forwarded-Host, still give each forwarded host its own entry.


Reviewed commit: 19c6a478

@ask-bonk

ask-bonk Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Workers Cache keys an entry by its path and query, entrypoint, ctx.props
and Worker version, not by host. vinext put the request's full URL into
the key it adds and the props it passes, so every host filled its own
copy, where Next.js keys its response cache by pathname.

The Workers Cache adapter now asks core for a host-free identity, which
keeps every dispatch's query. The gateway keys the cached entrypoint by
that identity and sends the host-bearing fields the render needs in an
internal header, which Workers Cache doesn't key.
…amic in probes

Workers Cache warms what its cacheability probe finds cacheable. A probe
dispatch bypasses the shared transport, so it never marked a Pages page
as shared across hosts, and the render-time getInitialProps veto, which
only applied to admission, never ran in it. A page whose getInitialProps
an imported wrapper attaches was then classified cacheable, and warming
waited for an entry its shared dispatch refuses to store.

A probe now marks the page shared across hosts, as its shared dispatch
is, without a cache identity, and the veto marks the render dynamic, as
Next.js counts a getInitialProps page, in probes and admission alike.
@james-elicx
james-elicx force-pushed the fix/workers-cache-host-free-key branch from 6877554 to 19c6a47 Compare October 20, 2026 23:18
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.

1 participant