You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
The ids come from a module-scope counter in website/components/copy-cmd.ts L25/L37
(let HINT_SEQ = 0 … copy-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.
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
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.tsshows200 OK (from disk cache)on arepeat visit, while
/and/docs/getting-startedalways transfer the full body (~72KB forthe home page). The site looks opted in (the root layout has set
cacheControlsince#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=0means the browser must revalidate every time. The value on every page is:max-age=0is the browser directive, so a stored copy is never reusable without around-trip.
s-maxage=600only ever applies to a shared cache. Contrast an app asset,which gets
public, max-age=3600fromdev.jsand therefore does produce a disk-cachehit. 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:The ids come from a module-scope counter in
website/components/copy-cmd.tsL25/L37(
let HINT_SEQ = 0…copy-cmd-hint-${HINT_SEQ++}) that never resets in a long-livedserver process. Every render of every page containing a
<copy-cmd>therefore producesdifferent bytes, a different ETag, and an
If-None-Matchthat 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=0the browserrevalidates 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, sos-maxage=600is never honoured. Cloudflare does not cacheHTML 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 paththrough
https://webjs.dev/. So even after cause 2 is fixed, a browser behind the CDN hasno 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 perrender rather than drawn from a process-lifetime counter. Options, roughly in order of
preference:
aria-labelon the copy target, or a<label>-style association that needs no unique id.Whichever is chosen, the id must be identical at SSR and on hydration, otherwise
aria-describedbymismatches 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()) silentlydisables 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), andprobably worth a line in the caching docs.
Header values (cause 1). Recommendation to confirm during implementation, not a settled
decision:
max-age=0. Deploys stay instantly visible, and the WebJs clientrouter 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=0is a304, not a 72KB body, which removes most of the reason to raise it.
s-maxage(600 is short for content that changes only on deploy) and keepstale-while-revalidate=86400so a revalidation is never in the user's path.(from disk cache)on pages, the smallest defensible browser valueis
max-age=60; anything longer risks a user holding stale HTML across a deploy.Do not raise
s-maxagebefore #1095 lands. That issue is the un-fingerprinted/public/tailwind.csslink going stale at the edge across a deploy. Caching the HTMLharder 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.tsL25 (let HINT_SEQ = 0) and L37 (_hintId). This isthe render-determinism bug. L93 and L104 are the two consumers of the id.
website/app/layout.tsL51, insidegenerateMetadata(), is the singlecacheControlthat 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.
packages/server/src/ssr.jsL481htmlResponse()(sets the header, stampsBUFFERED_MARKERto opt into the conditional-GET funnel) andpackages/server/src/conditional-get.js(isCacheable()L89,ifNoneMatchSatisfied()L104). The framework side is working correctly; the app is feeding it unstable bytes.
Landmines
https://webjs.up.railway.app), nothttps://webjs.dev.Cloudflare strips the ETag and forces
DYNAMIC, so testing through the CDN cannot tell youwhether a framework or app fix worked.
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.
about HTTP caching. Test with hard navigations (address bar / reload), or with curl.
copy-cmdis a shipping interactive component, so it is not elided; changes to it affectthe browser bundle, not just SSR output.
raise-
s-maxagehalf of this work. Sequence it after, or leaves-maxagealone.export const revalidate(the server HTML responsecache, 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
no-storeon purpose (AGENTS.md, built-ins). A publiccacheControlisonly safe because the page is visitor-identical and cookieless (action CSRF is an
Origin /
Sec-Fetch-Sitecheck, so noSet-Cookierides the SSR HTML). Nothing in thischange may introduce per-visitor content into a cached page.
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.tsandlayout-ssr.test.tsshow theexisting render harness.
website/test/components/browser/for the a11y assertion thataria-describedbystillresolves to a real element after hydration, if the id scheme changes rather than going away.
website/app/docs/cache/page.tsis the user-facing caching page and currently saysnothing 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.mdcachingsection needs the same.
Acceptance criteria
/and/docs/getting-startedare byte-identicalmodule-scope counter is restored (counterfactual verified)
If-None-Matchreturns304with no bodyaria-describedbyon the copy target still resolves to a real element, at SSR andafter hydration
cacheControlvalue is recorded with its rationale, including whetherbrowser
max-agestays 0are written down in this issue for the owner to apply