Skip to content

dogfood: a partial fragment is shared-cacheable if a CDN ignores Vary #1140

Description

@vivek7405

Problem

A reduced (X-Webjs-Have) fragment and a <webjs-frame> subtree are served with the PAGE's
Cache-Control, so on a page that opted into public caching they are marked shared-cacheable.
Their safety then rests entirely on the CDN honoring Vary. Cloudflare ignores Vary
except Accept-Encoding
, and so do several other shared caches, so a CDN can store a
chrome-less fragment under the page URL and serve it to a fresh full-page navigation.

Measured on the live site (webjs.dev origin, /docs/ai-first):

71,759 bytes   full page
47,375 bytes   same URL + x-webjs-have          <- chrome-less fragment
cache-control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400
vary: X-Webjs-Have, Accept-Encoding

Both responses carry the identical public Cache-Control. The only thing standing between a
visitor and a 47KB fragment rendered as a whole page is a Vary header that Cloudflare
documents as ignored.

This is latent rather than live today only because Cloudflare does not cache HTML by default,
so webjs.dev sits at cf-cache-status: DYNAMIC. It becomes live the moment an app does the
ordinary thing: put a WebJs app behind Cloudflare and turn on HTML caching, which is a normal
performance step and exactly what metadata.cacheControl invites. The failure is silent (no
error, a page that renders without its chrome) and cache-resident, so it persists for the
whole s-maxage window and can be served to many visitors.

The framework already reasons correctly about this for its OWN cache: the #241 server HTML
cache excludes any request carrying x-webjs-have, so a reduced body is never stored under
the URL-only key (ssr.js, the cacheEligible guard). The gap is that the same reasoning was
not applied to what SHARED caches are told.

Design / approach

A reduced fragment is per-client-state by construction, so it should never be shared-cacheable
at all. Rather than depending on Vary being honored, override Cache-Control on the reduced
and frame-subtree paths to a value no shared cache can store, while leaving the full-document
response's public caching exactly as it is.

Keep the Vary headers as belt-and-braces for caches that do honor them; the point is that
correctness must not DEPEND on them.

The value to send needs a small decision. private alone permits a browser-local store, which
is fine and arguably desirable (the client router's own snapshot cache already dedupes these,
and #1131 made router fetches revalidate). no-store is the maximally safe option. Prefer
private plus the existing freshness directives unless a reason emerges to forbid the local
store too, since these responses are exactly the ones a returning client can reuse.

Do NOT solve this by dropping the public cacheControl from pages: full-document edge caching
is the win the header exists for, and it is safe (the SSR response is cookieless, since action
CSRF is an Origin / Sec-Fetch-Site check).

Implementation notes (for the implementing agent)

Where to edit

  • packages/server/src/ssr.js, the reduced-response site where Vary: X-Webjs-Have is
    appended (search for if (reduced) res.headers.append('vary', 'X-Webjs-Have')). The
    Cache-Control override belongs beside it, gated on the same reduced flag.
  • The same file's frame path, where Vary: X-Webjs-Frame is appended to frameRes and
    Vary: X-Webjs-Have is added on top when the subtree came from a reduced render. A frame
    subtree is sliced by a request header too, so it needs the identical treatment.
  • The reduced flag itself originates in the layout-chain walk (the return { html: ..., reduced: true }
    short-circuit). No change needed there; it is context for why the flag exists.

Landmines

  • The Add a server HTML response cache with TTL and on-demand revalidation #241 server HTML cache is a SEPARATE mechanism and already safe (cacheEligible
    excludes x-webjs-have requests). Do not conflate the two or "fix" it twice.
  • A full-document response must keep its public Cache-Control byte-for-byte. The whole
    point of dogfood: webjs.dev pages are never cached at any layer #1127 was to make those edge-cacheable; a blanket downgrade would silently undo it.
    Assert the unchanged case, not just the changed one.
  • Conditional GET interacts here: packages/server/src/conditional-get.js isCacheable()
    treats a private response as NOT eligible for an ETag, so switching to private removes
    the validator from fragment responses. That is acceptable (the client router revalidates its
    own fetches per fix: a browser-cached page hides a deploy from the client router #1131 and dedupes via its snapshot cache), but it IS a behaviour change to
    verify rather than discover.
  • The client router sends x-webjs-router: 1 on its fetches; that header is not what triggers
    the reduced path (x-webjs-have is), so do not key the fix off it.

Invariants to respect

  • A shared cache must never be able to serve a partial fragment to a full-page navigation.
    After this change that must hold even if Vary is ignored entirely.
  • packages/ is plain .js with JSDoc.

Tests + docs surfaces

  • Integration through createRequestHandler: assert a request WITH x-webjs-have that
    produces a reduced body comes back non-shared-cacheable, and that the same URL WITHOUT the
    header keeps its public Cache-Control unchanged. Same pair for a <webjs-frame> request.
  • Counterfactual: the test must fail when the override is removed.
  • references/client-router-and-streaming.md documents the Vary: X-Webjs-Have contract and
    should state that fragments are additionally non-shared-cacheable, so the guarantee does not
    read as depending on Vary.
  • website/app/docs/cache/page.ts is the user-facing caching page; a sentence that
    metadata.cacheControl is safe to use behind a CDN belongs there, since that is the
    question this bug would otherwise raise.

Acceptance criteria

  • A reduced X-Webjs-Have response cannot be stored by a shared cache, independent of Vary
  • A <webjs-frame> subtree response gets the same treatment
  • A full-document response's Cache-Control is byte-identical to before
  • The existing Vary headers are still sent
  • A counterfactual proves the new tests fail when the override is removed
  • The client-router reference and the docs-site caching page state the guarantee

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions