Skip to content

dogfood: webjs.dev pages are never cached at any layer #1127

Description

@vivek7405

Problem

No HTML page on webjs.dev is ever served from a browser or CDN cache. In the DevTools
network tab, an asset like /components/copy-cmd.ts shows 200 OK (from disk cache) on a
repeat visit, while / and /docs/getting-started always transfer the full body (~72KB for
the home page). The site looks opted in (the root layout has set cacheControl since
#660), so this reads as a framework bug until you take the headers apart.

Three independent causes stack up. All three were verified against the live site and the
Railway origin, not inferred.

1. max-age=0 means the browser must revalidate every time. The value on every page is:

cache-control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400

max-age=0 is the browser directive, so a stored copy is never reusable without a
round-trip. s-maxage=600 only ever applies to a shared cache. Contrast an app asset,
which gets public, max-age=3600 from dev.js and therefore does produce a disk-cache
hit. So "pages are not cached" is, at the browser layer, exactly what the current header
asks for. The open question this issue has to settle is whether that is still the value we
want.

2. The page ETag is unstable, so revalidation can never answer 304. The origin does
emit a weak ETag, but two consecutive renders of / differ:

$ curl -s https://webjs.up.railway.app/ -o a.html
$ curl -s https://webjs.up.railway.app/ -o b.html
$ diff a.html b.html
340c340
<           aria-describedby="copy-cmd-hint-116"
---
>           aria-describedby="copy-cmd-hint-120"
(… 6 more, identical byte length)

The ids come from a module-scope counter in website/components/copy-cmd.ts L25/L37
(let HINT_SEQ = 0copy-cmd-hint-${HINT_SEQ++}) that never resets in a long-lived
server process. Every render of every page containing a <copy-cmd> therefore produces
different bytes, a different ETag, and an If-None-Match that can never match. Confirmed:
sending the origin's own ETag straight back gets a 200 with the full body, not a 304.

The consequence is worse than a missed optimisation. With max-age=0 the browser
revalidates on every page view, and because the validator never matches, every one of
those revalidations transfers the whole document instead of a ~200-byte 304.

3. Cloudflare does not cache the HTML and strips the ETag. Every HTML URL comes back
cf-cache-status: DYNAMIC, so s-maxage=600 is never honoured. Cloudflare does not cache
HTML without an explicit Cache Rule. It also removes the origin's ETag: it is present on
https://webjs.up.railway.app/ (W/"f89f82f267f6ef41") and absent on the same path
through https://webjs.dev/. So even after cause 2 is fixed, a browser behind the CDN has
no validator to send. This is a dashboard-only fix, the same class of finding as the
Cloudflare-managed robots.txt in #1088.

Net effect today: every visit to every page is a full origin render in a single region
(x-railway-edge: cdg1) plus a full-body transfer, with all three caching layers inert.

Design / approach

Fix the two causes we control in-repo, and document the third as an infrastructure change.

Stable render output (cause 2). copy-cmd's hint id needs to be deterministic per
render rather than drawn from a process-lifetime counter. Options, roughly in order of
preference:

  • Drop the generated id entirely and use aria-label on the copy target, or a
    <label>-style association that needs no unique id.
  • Derive the id from something already stable in the render (the command text, hashed).
  • Keep a counter but scope it to the request/render rather than the module.

Whichever is chosen, the id must be identical at SSR and on hydration, otherwise
aria-describedby mismatches when the element upgrades.

The deeper point is that ETag stability is a whole-page property: any per-render
nondeterminism anywhere in a page (a counter, Date.now(), Math.random()) silently
disables conditional GET for every page that renders it, with no error and no failing test.
That is worth a regression test that is not specific to copy-cmd (see below), and
probably worth a line in the caching docs.

Header values (cause 1). Recommendation to confirm during implementation, not a settled
decision:

  • Keep the browser at max-age=0. Deploys stay instantly visible, and the WebJs client
    router already keeps its own snapshot/prefetch cache, so in-session navigation does not
    touch the HTTP cache anyway. Once cause 2 is fixed, the per-view cost of max-age=0 is a
    304, not a 72KB body, which removes most of the reason to raise it.
  • Raise s-maxage (600 is short for content that changes only on deploy) and keep
    stale-while-revalidate=86400 so a revalidation is never in the user's path.
  • If we do want visible (from disk cache) on pages, the smallest defensible browser value
    is max-age=60; anything longer risks a user holding stale HTML across a deploy.

Do not raise s-maxage before #1095 lands. That issue is the un-fingerprinted
/public/tailwind.css link going stale at the edge across a deploy. Caching the HTML
harder compounds it: a cached document plus a cached-stale stylesheet is exactly the
edge-to-edge broken layout #1095 describes.

Cloudflare (cause 3). Needs a Cache Rule for HTML that respects origin headers, plus a
cache purge wired into deploy, plus a check on whichever HTML-modifying feature is stripping
the ETag (Email Address Obfuscation is on by default and rewrites the body). Owner-only,
dashboard side; this issue should record the required settings even though an agent cannot
apply them.

Implementation notes (for the implementing agent)

Where to edit

  • website/components/copy-cmd.ts L25 (let HINT_SEQ = 0) and L37 (_hintId). This is
    the render-determinism bug. L93 and L104 are the two consumers of the id.
  • website/app/layout.ts L51, inside generateMetadata(), is the single cacheControl
    that covers the whole site. Root-layout metadata merges into every page, so one edit here
    changes every route; there is no per-page override to hunt down.
  • Framework machinery to read but almost certainly not to change:
    packages/server/src/ssr.js L481 htmlResponse() (sets the header, stamps
    BUFFERED_MARKER to opt into the conditional-GET funnel) and
    packages/server/src/conditional-get.js (isCacheable() L89, ifNoneMatchSatisfied()
    L104). The framework side is working correctly; the app is feeding it unstable bytes.

Landmines

  • Verify against the Railway origin (https://webjs.up.railway.app), not https://webjs.dev.
    Cloudflare strips the ETag and forces DYNAMIC, so testing through the CDN cannot tell you
    whether a framework or app fix worked.
  • SSR never calls connectedCallback, and constructor/field initialisers run on both sides.
    A per-render id must be derived the same way in both environments or hydration mismatches.
  • The client router's snapshot cache means clicking between pages in-session proves nothing
    about HTTP caching. Test with hard navigations (address bar / reload), or with curl.
  • copy-cmd is a shipping interactive component, so it is not elided; changes to it affect
    the browser bundle, not just SSR output.
  • fix: fingerprint author-written asset links so a deploy busts the CDN copy #1095 (un-fingerprinted stylesheet link) is a hard dependency for the
    raise-s-maxage half of this work. Sequence it after, or leave s-maxage alone.
  • Do not "fix" this by reaching for export const revalidate (the server HTML response
    cache, Add a server HTML response cache with TTL and on-demand revalidation #241). That caches renders server-side; it does not address any of the three
    causes here, and fix(server): HTML response cache key omits the host, so X-Forwarded-Host poisons it #1097 is an open correctness bug against its cache key.

Invariants to respect

  • Pages default to no-store on purpose (AGENTS.md, built-ins). A public cacheControl is
    only safe because the page is visitor-identical and cookieless (action CSRF is an
    Origin / Sec-Fetch-Site check, so no Set-Cookie rides the SSR HTML). Nothing in this
    change may introduce per-visitor content into a cached page.
  • Light-DOM component, Tailwind utilities, no static styles (website AGENTS.md).

Tests + docs surfaces

  • website/test/ssr/ is the home for the regression test. The valuable one is generic:
    render a page twice and assert byte-identical HTML, so any future nondeterminism is
    caught, not just this counter. page-ssr.test.ts and layout-ssr.test.ts show the
    existing render harness.
  • website/test/components/browser/ for the a11y assertion that aria-describedby still
    resolves to a real element after hydration, if the id scheme changes rather than going away.
  • Docs: website/app/docs/cache/page.ts is the user-facing caching page and currently says
    nothing about render determinism being a precondition for the ETag/304 path. Worth a short
    note there, and check whether the framework skill's references/built-ins.md caching
    section needs the same.

Acceptance criteria

  • Two consecutive SSR renders of / and /docs/getting-started are byte-identical
  • A regression test asserts render determinism generically, and fails when the
    module-scope counter is restored (counterfactual verified)
  • Replaying the origin's ETag via If-None-Match returns 304 with no body
  • aria-describedby on the copy target still resolves to a real element, at SSR and
    after hydration
  • The chosen cacheControl value is recorded with its rationale, including whether
    browser max-age stays 0
  • Required Cloudflare settings (HTML Cache Rule, deploy purge, ETag-stripping feature)
    are written down in this issue for the owner to apply
  • Caching docs mention render determinism as a precondition for conditional GET

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