Summary
EmDash's middleware folds the build timestamp into every on-demand response's cache validator (context.cache.set({ lastModified: buildDate })) and no ETag is emitted anywhere. The intent (per the code comment) is sound — a code deploy must change the validator so returning visitors don't keep HTML referencing old content-hashed /_astro/* assets. But the validator has no content dimension: a content-only publish (the normal editorial workflow) never changes it.
Consequence: any browser that fetched a page before a publish revalidates with If-Modified-Since: <build time>; the current representation carries the same build-time Last-Modified → 304 Not Modified → the browser keeps its stale body and re-arms its freshness timer. This repeats indefinitely — staleness for returning visitors is unbounded, regardless of max-age, regardless of whether the edge/CDN cache was correctly purged, and regardless of a fresh re-render (which carries the same validator). Fresh clients (incognito, curl, cache-busting URLs) see the new content; every returning browser doesn't.
This affects any EmDash site whose HTML is browser-cacheable (any max-age > 0, or heuristic caching) — i.e., the common case. It effectively defeats any browser caching policy that intends to bound post-publish staleness to a few minutes.
Environment
emdash 0.35.0 (observed in production) — and verified the identical code path in 0.36.0 (latest)
astro 7.2.4, @astrojs/cloudflare 14.2.3, cacheCloudflare() provider + Workers Cache ("cache": { "enabled": true }), output: "server", D1
- Cloudflare Workers, custom domain
Root cause
dist/astro/middleware.mjs (0.36.0, ~lines 3775–3807):
const buildDate = buildTime ? new Date(buildTime) : null;
function applyBuildValidator(context) {
if (context.isPrerendered || !buildDate || !context.cache?.enabled) return;
context.cache.set({ lastModified: buildDate });
}
Astro's cache runtime keeps the later of lastModified values; route rules don't set etag/lastModified, and the Cloudflare provider's setConditionalHeaders() only emits what the options provide. Net result on every HTML response: Last-Modified: <build time>, no ETag.
Steps to reproduce
- EmDash site, server-rendered page that lists latest content of a collection (e.g. home page), route rule with
maxAge > 0, browser Cache-Control: public, max-age=300 (any positive value works).
- Deploy (build time = T0). In a normal browser, visit the page →
200, body without the new item. Note: Last-Modified: <T0>, no ETag.
- In the admin UI, create + publish a new item that changes the page (content-only change, no redeploy).
- Verify the server side is fresh: fetch via curl / incognito / cache-busting URL →
200 with the new item — and Last-Modified is still <T0> (unchanged).
- Back in the original browser, wait for
max-age to elapse and reload → DevTools shows the document request with If-Modified-Since: <T0> → response 304 Not Modified → stale page rendered.
- Repeat reloads — always
304. Only a new deploy (validator advances) or clearing browser cache shows the new content.
Observed in production (home page, Urdu locale):
- Edge (
cf-cache-status: HIT) held a post-publish entry — verified byte-level that it contained the newly published items — yet browsers that had visited pre-publish kept receiving 304 with If-Modified-Since equal to the build time.
curl -I https://<site>/ → cache-control: public, max-age=300, last-modified: <build time>, no etag.
Expected / Actual
- Expected: after a content publish, a returning visitor's conditional revalidation gets
200 with the updated body; unchanged content still 304s (cheap revalidation preserved).
- Actual:
304 forever within a deploy version.
Suggested fix
Emit a strong ETag derived from the rendered response body (hash) in addition to the build-time Last-Modified (which still covers the asset-404 case it was designed for). Conditional requests prefer If-None-Match, making 200/304 decisions content-accurate. Astro's cache runtime already supports options.etag — it's just never populated. Advancing lastModified from content metadata instead is harder to do correctly (e.g., an unpublish changes the rendered page while every remaining item's updatedAt is unchanged). Hashing cost is paid only on cache-miss renders.
Workarounds
- Redeploy (advances the build-time validator) — unblocks stuck browsers within one revalidation window.
- Site-level middleware that buffers the HTML response and sets a content-hash
ETag.
- Serve HTML
no-store (loses revalidation entirely).
Related issues found while diagnosing (happy to file separately)
- EmDash: MCP-server content mutations (
/api/mcp → handleContentCreate/Publish/Update/Delete…) never call the edge-cache cache.invalidate({ tags: … }) that the REST routes do — content changed via MCP (AI assistants) leaves edge caches stale until TTL.
- EmDash: the scheduled-publish sweep purges from the
scheduled() handler; per Cloudflare's docs, Workers Cache purges are scoped per entrypoint, so the purge may not affect the fetch entrypoint's cache.
- Astro (
@astrojs/cloudflare): the cacheCloudflare() provider discards the CachePurgeResult ({ success, errors }) returned by cache.purge() — purge failures are completely silent.
Summary
EmDash's middleware folds the build timestamp into every on-demand response's cache validator (
context.cache.set({ lastModified: buildDate })) and noETagis emitted anywhere. The intent (per the code comment) is sound — a code deploy must change the validator so returning visitors don't keep HTML referencing old content-hashed/_astro/*assets. But the validator has no content dimension: a content-only publish (the normal editorial workflow) never changes it.Consequence: any browser that fetched a page before a publish revalidates with
If-Modified-Since: <build time>; the current representation carries the same build-timeLast-Modified→ 304 Not Modified → the browser keeps its stale body and re-arms its freshness timer. This repeats indefinitely — staleness for returning visitors is unbounded, regardless ofmax-age, regardless of whether the edge/CDN cache was correctly purged, and regardless of a fresh re-render (which carries the same validator). Fresh clients (incognito, curl, cache-busting URLs) see the new content; every returning browser doesn't.This affects any EmDash site whose HTML is browser-cacheable (any
max-age > 0, or heuristic caching) — i.e., the common case. It effectively defeats any browser caching policy that intends to bound post-publish staleness to a few minutes.Environment
emdash0.35.0 (observed in production) — and verified the identical code path in 0.36.0 (latest)astro7.2.4,@astrojs/cloudflare14.2.3,cacheCloudflare()provider + Workers Cache ("cache": { "enabled": true }),output: "server", D1Root cause
dist/astro/middleware.mjs(0.36.0, ~lines 3775–3807):Astro's cache runtime keeps the later of
lastModifiedvalues; route rules don't setetag/lastModified, and the Cloudflare provider'ssetConditionalHeaders()only emits what the options provide. Net result on every HTML response:Last-Modified: <build time>, noETag.Steps to reproduce
maxAge > 0, browserCache-Control: public, max-age=300(any positive value works).200, body without the new item. Note:Last-Modified: <T0>, noETag.200with the new item — andLast-Modifiedis still<T0>(unchanged).max-ageto elapse and reload → DevTools shows the document request withIf-Modified-Since: <T0>→ response 304 Not Modified → stale page rendered.304. Only a new deploy (validator advances) or clearing browser cache shows the new content.Observed in production (home page, Urdu locale):
cf-cache-status: HIT) held a post-publish entry — verified byte-level that it contained the newly published items — yet browsers that had visited pre-publish kept receiving304withIf-Modified-Sinceequal to the build time.curl -I https://<site>/→cache-control: public, max-age=300,last-modified: <build time>, noetag.Expected / Actual
200with the updated body; unchanged content still 304s (cheap revalidation preserved).304forever within a deploy version.Suggested fix
Emit a strong
ETagderived from the rendered response body (hash) in addition to the build-timeLast-Modified(which still covers the asset-404 case it was designed for). Conditional requests preferIf-None-Match, making 200/304 decisions content-accurate. Astro's cache runtime already supportsoptions.etag— it's just never populated. AdvancinglastModifiedfrom content metadata instead is harder to do correctly (e.g., an unpublish changes the rendered page while every remaining item'supdatedAtis unchanged). Hashing cost is paid only on cache-miss renders.Workarounds
ETag.no-store(loses revalidation entirely).Related issues found while diagnosing (happy to file separately)
/api/mcp→handleContentCreate/Publish/Update/Delete…) never call the edge-cachecache.invalidate({ tags: … })that the REST routes do — content changed via MCP (AI assistants) leaves edge caches stale until TTL.scheduled()handler; per Cloudflare's docs, Workers Cache purges are scoped per entrypoint, so the purge may not affect thefetchentrypoint's cache.@astrojs/cloudflare): thecacheCloudflare()provider discards theCachePurgeResult({ success, errors }) returned bycache.purge()— purge failures are completely silent.