Affected package: @astrojs/cloudflare
Severity: Medium — Silent performance regression on every Cloudflare deployment
Branch: fix-cf-asset-cache
When deploying an Astro site to Cloudflare Pages or Workers, the hashed static assets under /_astro/* (JavaScript bundles, CSS, images, and other build outputs) are served with a Cache-Control: public, max-age=0, must-revalidate header. This forces the browser to revalidate every asset with the server before each use — meaning every page load requires round-trips to confirm that fonts, scripts, stylesheets, and other resources are still current, even though their content-addressed URLs guarantee they are unchanged.
Astro's build pipeline generates content-addressed asset filenames — e.g., /_astro/main.Ck3f8xpP.js. The hash in the filename is derived from the file's content, so the URL only changes when the content changes. This property makes these assets safe for the most aggressive browser cache policy: Cache-Control: public, max-age=31536000, immutable. Once a browser downloads main.Ck3f8xpP.js, it never needs to re-validate it because any future change to the JS will produce a new URL.
Cloudflare applies HTTP response headers to hosted assets via a _headers file placed in the output directory. Without a rule in that file covering /_astro/*, Cloudflare falls back to its default caching behavior, which emits Cache-Control: public, max-age=0, must-revalidate. This forces the browser to revalidate on every load, defeating the purpose of content-addressed filenames.
- A developer builds an Astro site with
@astrojs/cloudflareand deploys to Cloudflare Pages/Workers. - The
_headersfile in the output directory contains no rule for the assets path (/_astro/*or/<base>/_astro/*when a base path is configured). - Cloudflare serves
/_astro/*.js,/_astro/*.css, etc. withCache-Control: public, max-age=0, must-revalidate. - Browsers must revalidate every asset on every page load before fonts render, scripts execute, and styles apply.
The bug is compounded when base is set in astro.config.* (e.g., base: '/blog'). In that case the assets directory is /<base>/_astro/*. The integration had no awareness of the base path when constructing the _headers rule, so even a manually written rule for /_astro/* would fail to match the actual asset URLs.
The @astrojs/cloudflare integration's astro:build:done hook assembled the _redirects file and other Cloudflare-specific output artifacts, but contained no logic to write cache headers for hashed assets. The integration was responsible for all other _headers manipulation (e.g., in related PRs for KV namespaces and session injection) but this particular concern was never addressed.
Without an explicit rule in _headers for /_astro/*, Cloudflare applies its default header, Cache-Control: public, max-age=0, must-revalidate, which instructs browsers to revalidate every asset on each load rather than serving from cache.
| Scenario | Impact |
|---|---|
New Cloudflare deployment, no custom _headers |
Assets served with max-age=0, must-revalidate; browser revalidates every asset on every page load |
| Re-deployment with unchanged assets | Browser still revalidates assets on every load even though the URL (and content) has not changed |
Site with base path configured |
Cache headers for base-prefixed assets paths (/<base>/_astro/*) never injected; default revalidation header applies |
User has a Cache-Control rule in _headers matching /* |
Merging a new rule would produce contradictory comma-joined cache directives |
build.assetsPrefix set (assets on a CDN/other origin) |
No specific impact from this missing header, but the absence of any guard meant future injection logic could incorrectly target a path not served by Cloudflare at all |
- Create a new Astro project targeting Cloudflare:
npm create astro@latest -- --template minimal npm install @astrojs/cloudflare
- Configure
astro.config.mjs:import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ adapter: cloudflare(), });
- Run
astro build. - Inspect
dist/_headers— the file either does not exist or contains no rule for/_astro/*. - Deploy to Cloudflare Pages and open DevTools → Network. Observe that
/_astro/*.jsresponses carryCache-Control: public, max-age=0, must-revalidate; repeat page loads trigger revalidation requests for every asset.
Add base: '/blog' to astro.config.mjs, repeat the above, and verify that /blog/_astro/* also has no cache rule and receives the default revalidation header.
Add a public/_headers file:
/*
Cache-Control: no-cache
Build the project. If the integration naively appended a second Cache-Control line for /_astro/*, Cloudflare would merge both matching rules and produce Cache-Control: no-cache, public, max-age=31536000, immutable on the asset responses.
After build, dist/_headers (or dist/client/_headers for SSR output) should contain:
/_astro/*
Cache-Control: public, max-age=31536000, immutable
Or, when base: '/blog':
/blog/_astro/*
Cache-Control: public, max-age=31536000, immutable
The rule should appear before any user-defined rules so that it is readable first in merge order. If the user's existing _headers already sets Cache-Control on any rule whose URL pattern would match the assets path, the injection must be skipped entirely to avoid Cloudflare's comma-merge producing contradictory directives.
When build.assetsPrefix is configured, the assets are not served by Cloudflare at all, so no injection should occur.
- Cloudflare's
_headersformat is documented at here. Key behavior: when multiple rules match a single request, all matching rules' headers are merged (comma-joined for duplicates). When no rule matches, Cloudflare appliesCache-Control: public, max-age=0, must-revalidateby default. - The pattern-matching logic needed to detect pre-existing
Cache-Controlcoverage must implement Cloudflare's splat (*) and named-placeholder (:name) syntax — a plain substring check is insufficient. - This bug affects both
output: 'static'andoutput: 'server'/output: 'hybrid'builds (which write todist/client/).