Astro Static Asset Optimisation and Fingerprinting
You deployed a new Astro build and users are reporting a blank page, JavaScript errors, or CSS that looks like the old design. The symptoms point to one of two failure modes: the CDN is serving stale HTML that references hash filenames from the previous build, or the CDN is serving the new HTML but the referenced _astro/ files have not yet propagated from origin. This page diagnoses both conditions, clarifies how Astro’s content hashing pipeline interacts with CDN edge behaviour, and gives you the specific commands and config to resolve them.
Symptom Identification
Before touching any configuration, pinpoint which layer is misbehaving. Open the browser DevTools Network tab immediately after a fresh deploy and note:
| Symptom | What the network shows | Probable layer |
|---|---|---|
| Blank page, no console errors | 200 OK for HTML, but 404 for _astro/*.js |
CDN serving new HTML before _astro/ files reached origin |
| Old styles, new JS | 200 OK for all files but content is stale |
CDN cached the HTML; assets updated but HTML still old |
| Hydration mismatch error | 200 OK for all files; console: Hydration mismatch |
Mixed deploy — some edges have new HTML, some have old |
304 Not Modified loop |
304 on HTML even after hard-reload |
Missing or wrong Cache-Control on HTML entry points |
Run this curl command to inspect headers from the CDN edge directly:
curl -sI https://your-domain.example.com/ \
| grep -iE "(cache-control|age|etag|x-cache|cf-cache-status)"
A cf-cache-status: HIT with a high age value on the HTML document is the smoking gun for stale-HTML syndrome.
How Astro’s Hash Pipeline Actually Works
Astro delegates asset processing to Vite, which in turn uses Rollup’s output pipeline. The sequence is:
- Vite reads every file imported in
.astrocomponents and collects a dependency graph - Rollup bundles and tree-shakes JS/TS; PostCSS processes stylesheets
- Each output file is hashed — the hash is derived from the file’s content bytes using a truncated content hash
- Files are written to
dist/_astro/with the hash embedded in the name:index-BKdj3a1f.js - Astro writes HTML files to
dist/with<script>and<link>tags pointing to the hashed paths dist/.vite/manifest.jsonmaps source paths to hashed output paths for SSR and tooling
Critically, step 5 means the HTML is the only file that changes its references. The _astro/ files themselves are immutable: once written, a given hash URL will always serve the same bytes.
The practical consequence: CDN cache-key architecture must treat HTML and _astro/ files with opposite policies. HTML must always be revalidated; _astro/ files can be cached permanently.
Side-by-Side: Filename Hashing vs Query-String Versioning
Astro uses filename hashing exclusively. Understanding why helps when diagnosing CDN behaviour and when evaluating the implementing cache keys with query parameters vs filenames tradeoff.
| Dimension | Filename hash (/index-BKdj3a1f.js) |
Query string (/index.js?v=BKdj3a1f) |
|---|---|---|
| CDN cache key | Unique per content by default | Varies by CDN — many strip query strings |
Cache-Control strategy |
immutable safe — URL never reused |
Must use no-cache or short TTL to avoid stale hits |
| Rollback | Re-deploy old HTML; old hash URLs still valid on CDN | Old URL still valid; must invalidate or purge |
| Nginx/Varnish default | Caches as distinct objects | May serve same cached object ignoring ?v= |
| Cloudflare default | Caches distinct objects | Caches distinct objects (Cloudflare respects query strings) |
| SEO impact | Negligible (crawlers follow canonical) | Negligible |
Astro does not provide a built-in query-string versioning mode. If a downstream system requires fixed filenames with version suffixes, you would need a post-build transform script — but this negates most of the immutable-caching benefits.
Enforcing the Correct Cache-Control Split
The single most important operational configuration is applying opposite Cache-Control policies to HTML and hashed assets. Without this split, the deploy sequence cannot be atomic from the CDN’s perspective.
Nginx
# Apply to the Nginx server block serving your Astro dist/
location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
gzip_static on;
}
location ~* \.(html)$ {
add_header Cache-Control "no-cache, must-revalidate";
add_header Vary "Accept-Encoding";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
If you changed build.assets to something other than _astro, update the location /_astro/ path to match your custom prefix.
Cloudflare Cache Rules
In the Cloudflare dashboard under Cache Rules, create two rules evaluated top-to-bottom:
Rule 1 — Immutable assets:
- Expression:
http.request.uri.path contains "/_astro/" - Cache eligibility: Eligible for cache
- Edge TTL: Respect origin, or set Custom with 365 days
- Browser TTL: Override — 31536000 seconds
- Add response header:
Cache-Control: public, max-age=31536000, immutable
Rule 2 — HTML revalidation:
- Expression:
http.request.uri.path matches "\.html$"ORends with "/" - Browser TTL: Override — 0 seconds
- Add response header:
Cache-Control: no-cache, must-revalidate
After a deploy, purge only the HTML paths at the CDN edge:
# Purge only HTML — never purge _astro/ paths
curl -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"files":["https://your-domain.example.com/","https://your-domain.example.com/about/","https://your-domain.example.com/blog/"]}'
For background on CDN-level purge strategies, see the Cloudflare cache rules and purge reference.
Runnable Verification Commands
Check headers on a hashed asset
ASSET_URL="https://your-domain.example.com/_astro/index-BKdj3a1f.js"
curl -sI "$ASSET_URL" | grep -iE "(http/|cache-control|age|etag|cf-cache-status)"
Expected:
HTTP/2 200
cache-control: public, max-age=31536000, immutable
age: 14521
etag: "BKdj3a1f"
cf-cache-status: HIT
The age header should climb on repeated requests. If it resets to 0 each time, the CDN is bypassing cache — check that Cloudflare is not set to “Development Mode” and that the rule expression matches.
Validate all manifest entries exist in dist/
#!/bin/bash
set -euo pipefail
MANIFEST="dist/.vite/manifest.json"
if [ ! -f "$MANIFEST" ]; then
echo "ERROR: manifest not found at $MANIFEST" >&2
exit 1
fi
MISSING=0
while IFS= read -r asset_path; do
target="dist/${asset_path}"
if [ ! -f "$target" ]; then
echo "MISSING: $target"
MISSING=$((MISSING + 1))
fi
done < <(python3 -c "
import json, sys
with open('$MANIFEST') as f:
m = json.load(f)
for v in m.values():
if 'file' in v:
print(v['file'])
for imp in v.get('imports', []):
chunk = m.get(imp, {})
if 'file' in chunk:
print(chunk['file'])
")
if [ "$MISSING" -gt 0 ]; then
echo "FATAL: $MISSING assets missing from dist/. Aborting deploy." >&2
exit 1
fi
echo "OK: all manifest assets present."
Run this script in CI after npx astro build and before uploading to your CDN or object store.
When to Reconsider This Approach
Filename hashing with immutable caching is the right default for Astro projects. Consider alternatives in these specific scenarios:
You need fixed asset URLs. Some integrations — payment SDKs, analytics snippets, A/B testing tools — require assets at predictable paths. Place those in public/ deliberately and document that they are intentionally unversioned. Do not attempt to work around this with symlinks or server rewrite rules.
You are behind a CDN that strips query strings and mangles filenames. A small number of legacy CDN appliances apply URL normalisation that strips characters like [ or ] from filenames, breaking Vite’s pattern. In that case, custom assetFileNames patterns using only alphanumeric characters and hyphens are necessary.
Your deploy pipeline cannot tolerate the two-file-type atomic sequence. Some platforms atomically swap entire directories (Netlify, Cloudflare Pages, Vercel). On those platforms, stale-HTML syndrome cannot occur because old and new versions of the site are served atomically. Astro’s default hashing requires no extra configuration on these platforms — the CDN purge step is handled for you.
You need public/ files versioned. If legacy constraints force critical assets into public/, write a pre-build script that computes a content hash for each file, renames it, and injects the hashed path into a lookup table your templates consume. This is the same pattern used by the rollup asset manifest for CDN deploys approach.
Frequently Asked Questions
Why does Astro serve 404s for assets immediately after a successful deploy?
The deploy completed but the CDN is still serving the previous version of index.html, which references old hash filenames. Those old files have been replaced on the origin by the new build. The fix: purge only the HTML URLs at the CDN edge immediately after deploying. The old _astro/ files are gone from origin but will not be requested once the HTML is updated.
Can I use query-string versioning instead of filename hashing?
Astro does not support query-string versioning natively. Vite’s output pipeline always produces filename hashes. Implementing query-string versioning would require a post-build transform that renames files back to their original names and rewrites all references — this eliminates the immutable-caching benefit and is not recommended.
How do I confirm that immutable headers are actually preventing revalidation requests?
Run curl -sI on a known hashed asset twice within a few minutes. The second response should show age greater than 0 and cf-cache-status: HIT (Cloudflare) or x-cache: Hit (other CDNs). If age is always 0, the CDN is not caching the asset — check for Vary headers that may be fragmenting the cache key beyond Accept-Encoding.
Related
- Astro build-time hashing — parent reference: full configuration,
build.assetsPrefix,build.assets, and Vite passthrough options - Vite asset pipeline configuration — the underlying Vite config layer Astro exposes
- Implementing cache keys with query parameters vs filenames — when query strings are a viable alternative
- Cloudflare cache rules and purge — setting up the HTML-only purge workflow on Cloudflare