Skip to content

MA2153/astro-assets-cf-repro

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Bug Report: Cloudflare Integration Does Not Cache Hashed Assets

Affected package: @astrojs/cloudflare
Severity: Medium — Silent performance regression on every Cloudflare deployment
Branch: fix-cf-asset-cache


Summary

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.


Background

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.


Observed Behavior

  1. A developer builds an Astro site with @astrojs/cloudflare and deploys to Cloudflare Pages/Workers.
  2. The _headers file in the output directory contains no rule for the assets path (/_astro/* or /<base>/_astro/* when a base path is configured).
  3. Cloudflare serves /_astro/*.js, /_astro/*.css, etc. with Cache-Control: public, max-age=0, must-revalidate.
  4. Browsers must revalidate every asset on every page load before fonts render, scripts execute, and styles apply.

With a custom base path

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.


Root Cause

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.


Impact

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

Reproduction Steps

  1. Create a new Astro project targeting Cloudflare:
    npm create astro@latest -- --template minimal
    npm install @astrojs/cloudflare
  2. Configure astro.config.mjs:
    import cloudflare from '@astrojs/cloudflare';
    export default defineConfig({
      adapter: cloudflare(),
    });
  3. Run astro build.
  4. Inspect dist/_headers — the file either does not exist or contains no rule for /_astro/*.
  5. Deploy to Cloudflare Pages and open DevTools → Network. Observe that /_astro/*.js responses carry Cache-Control: public, max-age=0, must-revalidate; repeat page loads trigger revalidation requests for every asset.

Variant: base path

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.

Variant: conflicting user _headers

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.


Expected Behavior

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.


Notes

  • Cloudflare's _headers format 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 applies Cache-Control: public, max-age=0, must-revalidate by default.
  • The pattern-matching logic needed to detect pre-existing Cache-Control coverage must implement Cloudflare's splat (*) and named-placeholder (:name) syntax — a plain substring check is insufficient.
  • This bug affects both output: 'static' and output: 'server' / output: 'hybrid' builds (which write to dist/client/).

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors