Skip to content

Returning visitors get stale pages forever after content-only publishes: build-time Last-Modified validator never advances and no ETag is emitted, so conditional revalidation always returns 304 #2883

Description

@segmentationfaulter

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-Modified304 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

  1. 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).
  2. Deploy (build time = T0). In a normal browser, visit the page → 200, body without the new item. Note: Last-Modified: <T0>, no ETag.
  3. In the admin UI, create + publish a new item that changes the page (content-only change, no redeploy).
  4. 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).
  5. 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.
  6. 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)

  1. EmDash: MCP-server content mutations (/api/mcphandleContentCreate/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.
  2. 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.
  3. Astro (@astrojs/cloudflare): the cacheCloudflare() provider discards the CachePurgeResult ({ success, errors }) returned by cache.purge() — purge failures are completely silent.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions