Skip to content

Let caches hold the public reads for a minute - #213

Merged
ssavutu merged 1 commit into
mainfrom
perf/public-cache-headers
Aug 8, 2026
Merged

Let caches hold the public reads for a minute#213
ssavutu merged 1 commit into
mainfrom
perf/public-cache-headers

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 8, 2026

Copy link
Copy Markdown
Member

Follow-up to #212. Only /v1/homepage stated a freshness bound, so
everything else was left to whatever heuristic an intermediary picked —
and Scalene's fetch cache has no expiry to respect at all, which is why
the homepage got a header in the first place.

public, max-age=60, stale-while-revalidate=300 (the bound the homepage
already used) now covers:

  • /v1/articles and /v1/articles/{slug}
  • /v1/sections/{slug}/articles, /v1/subsections/{slug}/articles
  • /v1/authors/{slug}/articles
  • /v1/articles/{slug}/comments
  • /v1/sitemap/slugs
  • /v1/settings/{site,footer,seo,breaking-news,homepage-carousel}

The part worth reviewing

Five of those sit behind OptionalAuth and answer two audiences at one
URL
— anonymously the published view, and to a signed-in editor the same
URL also carries drafts and soft-deleted rows. Marking an editor's copy
public would let a shared cache serve unpublished headlines to readers.

So the header depends on the caller: anonymous gets the public bound, a
request carrying credentials gets private, no-store. Vary: Cookie, Authorization names the difference, added rather than set so the
compression middleware's Vary: Accept-Encoding survives.

Cloudflare caveat, recorded on the helper: CF honours Vary only for
Accept-Encoding. The Vary here protects standards-compliant caches and
Scalene's fetch cache — a CDN rule that caches this API needs to bypass on
cookie rather than rely on it.

Deliberately excluded

  • /v1/articles/random — caching it defeats the endpoint.
  • /v1/search — query-string cardinality makes the hit rate poor.
  • Everything behind RequireAuth — no public form to cache.

Verification

  • Response bodies byte-identical to main across all eleven endpoints
    against the real corpus. This is headers and nothing else.
  • 404s and 500s carry no Cache-Control: the header is set immediately
    before the successful write, not at handler entry. A slug that 404s for
    sixty seconds because a reader beat the publish is a support ticket.
  • Integration tests assert the editor branch through the real handler, and
    fail loudly if the draft isn't in the body so they can't pass vacuously.
  • go vet clean, full suite including MariaDB integration tests passes.

🤖 Generated with Claude Code

Only /v1/homepage stated a freshness bound, so every other public read was
left to whatever heuristic an intermediary picked -- and Scalene's fetch
cache has no expiry to respect at all, which is the same reason the
homepage got a header in the first place. Article detail, the section and
subsection listings, author listings, comment threads, the sitemap and the
site settings now carry the same bound.

Routes behind OptionalAuth answer two audiences at one URL: anonymously
the published, non-archived view, and to a signed-in editor the same URL
also carries drafts and soft-deleted rows. Those get the header only when
the request is anonymous; a request carrying credentials is marked
private, no-store, because a shared cache holding an editor's copy would
serve unpublished headlines to readers. Vary names what distinguishes
them, added rather than set so the compression middleware's own
Vary: Accept-Encoding survives.

Note that Cloudflare honours Vary only for Accept-Encoding, so the Vary
here protects standards-compliant caches and Scalene; a CDN rule that
caches this API has to bypass on cookie rather than rely on it. That is
recorded on the helper.

The header is set immediately before the successful write, so 404s and
500s stay uncacheable -- a slug that 404s for sixty seconds because a
reader beat the publish is a support ticket.

The homepage's own constant is gone; it and everything else now share
publicReadCacheControl, which carries the bound the homepage needed.

Verified against the corpus: response bodies are byte-identical to main
across all eleven endpoints, so this is headers and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ssavutu
ssavutu merged commit dda3bf8 into main Aug 8, 2026
6 checks passed
@ssavutu
ssavutu deleted the perf/public-cache-headers branch August 8, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant