Cache behavior at the edge

Published

September 30, 2026

Not everything on bioconductor.org is cached at the edge:

Request Cached at the edge?
Legacy HTML the site build does not have (checkResults/ reports, old-release pages) No, read from R2 on every request
/packages/stats/ No. The .tab files, HTML pages and static/ files are read from R2 on every request
/_pr/<n>/ and /_latest/ previews No (no-cache)
404s Yes, for ten minutes
Pages from the site build Yes, until the next build
Package files and repository metadata (tarballs, binaries, PACKAGES*, VIEWS) Yes, until the sync purges them

For what is cached, freshness is purge-based rather than expiry-based: objects stay at the edge until purged, and the hourly sync purges exactly the URLs it changed, on every hostname the Worker serves. Nothing except a 404 falls out of cache on its own. Unchanged content is served from the edge with no revalidation, so when a page is stale, waiting will not fix it. Somebody has to purge.

This page is how to tell which case you are in, and what to do about it.

Is this page cached?

curl -sI https://bioconductor.org/ \
  | grep -iE 'cf-cache-status|^age|cache-control|x-bioc-build'

A representative response (measured 2026-09-30):

cf-cache-status: HIT
age: 80357
cache-control: public, max-age=300, s-maxage=31536000
x-bioc-build: 360bb871e039dfa9f5505506ecd8c9b405846b71
Header What it tells you
cf-cache-status Where the response came from. HIT = the edge, never reached storage. MISS = fetched from R2 and cached on the way past. Also EXPIRED, REVALIDATED, and DYNAMIC (not cacheable).
age Seconds this object has been in the edge cache. 80357 is about 22 hours.
cache-control max-age=300 is the browser’s five minutes (30 seconds for PACKAGES/VIEWS, which R reads on every install); s-maxage=31536000 is the edge’s one year. The asymmetry is the purge-based model in one line. The zone’s Browser Cache TTL is set to respect these headers.
x-bioc-build The commit that produced this page, as a bare sha. Present only on pages served from the site build. For those pages this is the header that answers “am I looking at something stale?”

Query strings do not bust the cache

Appending ?x=1 and re-requesting does not work here. The Worker normalises the cache key, so a query string returns the same cached object:

$ curl -sI 'https://bioconductor.org/?cachebust=12345' | grep -iE 'cf-cache-status|^age'
cf-cache-status: HIT
age: 80357

Identical age to the request without the query string, so it is the same cache entry. Anyone debugging a stale page this way will conclude the cache is fine when it is not.

Use x-bioc-build instead. Compare the sha it reports against the build you expect:

curl -sI https://bioconductor.org/<path> | grep x-bioc-build

If it names an older commit than the one you just published, the edge is stale and needs a purge. If it names the commit you expect, the edge is current and the problem is somewhere else, such as a build that did not contain your change.

Anyone can read the header without access to anything. It is set only on responses served from the site build (site/<sha>/). Package files and other mirror content, /packages/stats/ responses (the .tab files from R2 and the passthrough) and 404 responses do not carry it (passthrough checked 2026-09-30). For mirror content served from the edge cache, age shows how long the edge has held the copy; /packages/stats/ responses are never edge-cached, so they have neither header.

What is cached, and under which key

The edge cache is keyed by the storage object a request resolves to, not by the URL, so /help/, /help and /help/index.html share one entry, and release resolves to its numbered version first.

  • Pages from the new site build are keyed by the build’s commit, so publishing a new build moves every page to new keys without a purge.
  • Package files and other mirror-only content (tarballs, PACKAGES*, VIEWS, binaries, checkResults/, config.yaml, and the same inside the archive of old releases) are cached under their plain key, which is exactly what the sync purges when a package changes.
  • Legacy HTML the build doesn’t have yet is served from storage every time, uncached. A cached copy could hide the page once a later build adds it. This is the one class of request that still reads storage on every hit, and it shrinks as the build covers more.
  • 404s are cached for ten minutes (s-maxage=600), keyed by path and the current build commit. A new build clears them at once; a file published by the sync can 404 for up to ten minutes at a location that asked for it just before. That’s the price of not paying several storage reads for every repeated probe. One day of traffic measures what a longer TTL would save, and why the edge hit rate is what it is.

URLs that are never cached

Everything under /packages/stats/ (the .tab files, the HTML pages and static/barchart.js) is read from R2 with max-age=300, and no edge cache or purge: bioc-traffic rewrites the tree daily. A key R2 lacks is a 404; nothing under this prefix goes to master any more (bioc-edge#68).

Preview paths are served no-cache and carry no cf-cache-status at all:

  • /_pr/<n>/, one prefix per pull request, on the PR-preview host https://bioc-dev.cancerdatasci.org/_pr/<n>/. The prefix currently also works on production hostnames (bioc-edge#48).
  • /_latest/, whatever site/latest currently points at

This is why a preview always shows your most recent push. It is also why a preview URL is the right thing to hand someone when the question is “does this change look right”, and the wrong thing for judging performance.

Purging

Normally you do not: the sync purges what it uploads, on every run. Purge by hand when that did not happen, or when testing. A purge only makes the edge re-read R2, so it cannot stop a bad file being served; for that, see pull a bad package.

The helpers live in the serving repo (cf-purge.sh), and are shared by the sync and manifest scripts rather than copied, because both spend the same account-wide rate budget.

. ./cf-purge.sh                    # defines the functions; the calls below need CLOUDFLARE_API_TOKEN

cf_purge_keys packages/3.23/bioc/html/limma.html   # every served host, every zone

Confirm it worked by re-requesting: expect cf-cache-status: MISS and age: 0.

cf_purge_keys takes storage keys (so 3.23, not release) and purges them on every host in PURGE_TARGETS: bioconductor.org, www., next. and the dev host. The edge keeps a separate copy per hostname, so purging one host leaves the others stale.

To discard the entire edge cache of one zone:

cf_purge_everything "$(cf_zone_id bioconductor.org)"

The edge copies live in Cloudflare’s HTTP cache (the Worker’s caches.default), separate from R2, which holds the objects themselves. After a purge everything is re-fetched from R2 on next request. That is affordable here (static objects, no egress fees), but it throws away every warm object for the whole zone, so it belongs to real incidents rather than routine debugging.

WarningDo not hand-roll a purge loop

Purge-by-URL is the only targeted option below Enterprise: 100 URLs per request, and 800 URLs/second account-wide. That budget reads as generous and is not: a naive serial loop of 100-URL requests moves roughly 1,000 URLs/s and trips within seconds.

Two sync runs on 2026-08-04 died exactly there, at 2,863 URLs after 4 seconds and 5,320 after 15. The failure aborted the batch loop, so most of each run’s changed URLs stayed stale at the edge while the upload itself had succeeded. That is the worse half: a failed run is visible, a silently unpurged edge is not.

cf_purge_urls paces to about 500 URLs/s and retries rate limits rather than dropping the remaining batches. Use it.

The budget is per account, not per zone, so a manual purge while a sync is running spends from the same bucket and can starve it.


The example headers are a measurement from 2026-09-30 on bioconductor.org. The mechanism is stable; the numbers are illustrative. The “what is cached” rules are as of 2026-09-29 (bioc-edge#35, #36, #37, #38, #39).