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.

Post-deploy CDN state: correct vs stale Two columns compare the correct state after deploy (CDN has new HTML pointing to new _astro files) versus the stale state (CDN still serves old HTML pointing to old hash URLs while the new _astro files have replaced the old ones at origin, causing 404s). Correct state CDN: index.html (new) references _astro/index-NEW.js CDN: _astro/index-NEW.js 200 OK — immutable Page loads correctly Stale HTML state CDN: index.html (OLD) references _astro/index-OLD.js _astro/index-OLD.js 404 — file replaced at origin Blank page / JS error
After deploy, the CDN must serve the new HTML. If the CDN still holds a cached copy of the old HTML, it requests the old hashed asset — which is no longer at origin.

How Astro’s Hash Pipeline Actually Works

Astro delegates asset processing to Vite, which in turn uses Rollup’s output pipeline. The sequence is:

  1. Vite reads every file imported in .astro components and collects a dependency graph
  2. Rollup bundles and tree-shakes JS/TS; PostCSS processes stylesheets
  3. Each output file is hashed — the hash is derived from the file’s content bytes using a truncated content hash
  4. Files are written to dist/_astro/ with the hash embedded in the name: index-BKdj3a1f.js
  5. Astro writes HTML files to dist/ with <script> and <link> tags pointing to the hashed paths
  6. dist/.vite/manifest.json maps 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.

Note where in that sequence the hash is decided. It is computed in step 3, after bundling and tree-shaking, from the bytes Rollup is about to write — not from the source file. Two source trees that minify to identical output produce identical hashes; one source tree built with a different minifier setting produces different hashes from identical sources. Everything downstream of step 3 — the HTML rewrite, the manifest, the upload, the edge cache entry — is a consequence of that one number, which is why a diagnosis that starts at the CDN almost always ends at the build.

Astro build stage timeline Six numbered stages laid along a timeline: Vite collects the module graph, Rollup bundles and code-splits, output bytes are hashed to eight hex characters, hashed files are emitted to the _astro directory, HTML script and link tags are rewritten to the hashed paths, and the Vite manifest is written. The last three stages produce immutable output. Collect graph 1 Vite scans src Bundle + split 2 Rollup chunks Hash bytes 3 output to 8 hex Emit _astro/ 4 hashed files Rewrite HTML 5 tags get hashes Write manifest 6 .vite/manifest these outputs are immutable
The hash is fixed at stage 3; every later stage — filenames, HTML references, manifest — simply carries it forward.

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.

The two policies are mirror images. HTML is a mutable document at a stable URL, so it must be revalidated on every navigation; a browser that holds a cached copy for even five minutes will keep asking for asset hashes that no longer exist. Hashed assets are immutable documents at unique URLs, so revalidating them is pure waste — the answer can never be anything but the same bytes. Get this pair backwards and you invert the failure: a no-cache on _astro/ costs you a conditional request per asset per page view, and a long max-age on HTML pins users to a build you have already rolled back.

Opposite cache policies per file class Two horizontal lanes. The HTML lane shows a browser that revalidates, a CDN edge that misses and goes to origin, an origin returning new HTML, and a policy card reading no-cache must-revalidate. The hashed asset lane shows a browser that never asks again, a CDN edge serving a hit with a climbing age header, an origin holding the hashed file, and a policy card reading max-age 31536000 immutable. One deploy, two opposite policies HTML entry points Browser revalidates CDN edge miss, ask origin Origin new HTML no-cache must-revalidate Hashed _astro/ files Browser no request CDN edge hit, age climbs Origin rarely touched max-age=1y immutable
The same deploy needs revalidation on HTML and permanent caching on hashed assets — one rule cannot serve both.

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$" OR ends 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.

Prove the live HTML and the live assets agree

The manifest check above proves your build is internally consistent. It says nothing about what the edge is currently serving, which is the actual question during an incident. This one-liner fetches the live HTML, extracts every _astro/ reference from it, and issues a HEAD for each — any non-200 is the exact file the browser is failing to load:

SITE="https://your-domain.example.com"

curl -s "$SITE/" \
  | grep -oE '/_astro/[A-Za-z0-9._-]+\.(js|css|woff2|webp|svg)' \
  | sort -u \
  | while read -r path; do
      code=$(curl -s -o /dev/null -w '%{http_code}' -I "${SITE}${path}")
      printf '%s  %s\n' "$code" "$path"
    done

A clean run prints 200 for every line. A 404 means the edge is serving HTML from a build whose assets are no longer at origin — purge the HTML path and re-run. A mix of 200 and 404 across repeated runs points at an incompletely propagated deploy: some points of presence hold the new HTML, some the old. Run the same loop against a second region (or through a VPN exit in another continent) to confirm; if one region is clean and another is not, the problem is propagation timing, not the build.

Keep this loop in your runbook rather than your CI pipeline. In CI the manifest validator is faster and catches the same class of error before anything ships; against production, this is the only check that reflects what a real browser will experience.

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.

A hydration mismatch appeared right after deploy — is that a hashing problem?

Usually yes, indirectly. A hydration mismatch means the server-rendered markup and the client bundle disagree, which happens when the browser loaded HTML from one build and an island chunk from another. That is only possible when two builds are live at once: a stale HTML document at one edge referencing a chunk name that also exists in the new build, or a partially propagated deploy. Confirm by comparing the hash in the loaded chunk URL against the hash in your current dist/.vite/manifest.json. If they differ, the fix is a purge of HTML paths, not a code change.

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.