Rolling Back a Vite Asset Hash After a Bad Deploy
A bad Vite build in production announces itself in one of three ways: a blank screen caused by a failed chunk load, a JavaScript TypeError from mismatched module versions served across requests, or a cascade of 404s as dynamic imports reference hashed filenames that never reached the origin. Each of these symptoms shares a root cause — the new hashed assets are inconsistent with what browsers are trying to fetch, or the HTML entry point now references URLs that are incorrect. The fix is not a rebuild. It is a redeployment of the exact artifact that previously worked.
This page walks through the complete rollback workflow: identifying the discrepancy between manifests, restoring a prior dist/ artifact, making surgical CDN corrections, and verifying that all asset URLs respond correctly before lifting any traffic restrictions. If you are setting up fingerprinting for the first time rather than recovering from a bad deploy, start with Vite Asset Pipeline Configuration and the guide on how to configure content hashing in Vite production builds.
Symptom and Decision Framing
Before touching infrastructure, confirm that the issue is a bad deploy and not a CDN propagation lag or a misconfigured cache rule. The diagnostic is fast.
Open browser DevTools on an affected user session and look at the Network tab. Filter for JavaScript and CSS. If you see net::ERR_ABORTED 404 on paths like /assets/js/vendor-3f8a1b2c.js, the origin does not have that file — either the new build never shipped it or the deploy was partial. If you see 200 OK on all asset requests but the application still errors, the issue is likely a version skew between the HTML served from cache (referencing old hashes) and fresh assets served from origin (using new hashes).
In both cases, dist/.vite/manifest.json is the ground truth. Compare the manifest from the bad build against the one from the prior good build:
diff <(jq -r 'to_entries | sort_by(.key) | .[] | "\(.key): \(.value.file)"' /tmp/prior-manifest.json) \
<(jq -r 'to_entries | sort_by(.key) | .[] | "\(.key): \(.value.file)"' dist/.vite/manifest.json)
Lines prefixed with < are what was previously working. Lines prefixed with > are what the bad build introduced. If an entry point changed hash but several vendor chunks stayed the same — or vice versa — you can pinpoint exactly which URLs are now broken and which old URLs users still need to reach.
The decision that follows is binary: redeploy the prior artifact, or rebuild from the prior git tag. In almost every situation, redeploying the prior artifact is the correct choice. The reasons are explained in the decision matrix below.
Why Artifact Redeployment Beats a Rebuild
The instinct to git checkout v1.2.3 && npm run build feels correct — you have the source, you know the version, the build should reproduce itself. In practice, Vite and Rollup builds are deterministic with respect to source content but not with respect to the tool chain. Plugin updates, Node.js version differences, transient changes to node_modules lock-file resolution, or even OS-level changes to file system ordering can all cause Rollup to emit different chunk boundaries and therefore different content hashes.
The deterministic build outputs documentation covers the exact mechanisms by which seemingly identical builds diverge. The practical consequence: a rebuild from the prior commit might produce files with different names than the ones users currently have cached. You would then need to coordinate three moving parts simultaneously — old cached HTML, new HTML, and two sets of assets — instead of two.
Contrast this with a prior artifact redeployment. The dist/ directory that passed CI and shipped previously is frozen. Its hashes are known. Its manifest maps exactly to the files inside it. Users whose browsers already fetched assets from that deploy will hit cache. Users who land during the rollback window will get correct responses from origin immediately.
Decision Matrix: Artifact Redeployment vs. Rebuild from Commit
| Criterion | Redeploy prior artifact | Rebuild from prior commit |
|---|---|---|
| Speed to recovery | Fast — copy files, point CDN | Slow — full CI pipeline run |
| Hash determinism | Guaranteed — artifact is frozen | Not guaranteed — plugin versions may differ |
| Risk of new hash set | None | Present — any tool chain drift creates new URLs |
| Requires stored artifact | Yes | No — only git history needed |
| Safe when artifact store is unavailable | No | Yes |
| Correct when prior build had a latent config bug | No — that bug redeploys too | Possibly — if you fix config before rebuilding |
The only scenario that favors a rebuild is when your artifact store does not retain prior builds, or when the prior build itself had a configuration defect you need to correct as part of the rollback. Both of these edge cases are covered in the “When to Reconsider” section at the end of this page.
What Changes and What Does Not During Rollback
A critical property of content-addressed asset fingerprinting is that hashed files at their original URLs never disappear unless you explicitly delete them. When you deployed the bad build, you uploaded new hashed files alongside (or on top of) the old ones. The old hashed files are almost certainly still present on your origin.
What the rollback actually changes is much narrower than it appears.
| Component | Changes during rollback | Notes |
|---|---|---|
dist/.vite/manifest.json |
Yes — replaced with prior version | This is the authoritative map of what is live |
index.html (and any other entry HTML) |
Yes — replaced with prior version | References prior hashed asset URLs |
| CDN pointer for HTML | Yes — invalidate/purge HTML only | Forces edge to re-fetch the prior index.html |
| Hashed JS and CSS files on origin | No — prior files are already there | Content-addressed URLs are stable |
| Hashed asset CDN cache entries | No — prior asset URLs were never evicted | Immutable cache entries for old hashes remain valid |
| CDN cache entries for new (bad) asset hashes | No action required | New hashes simply stop being referenced by HTML |
This asymmetry is the core insight behind cache key architecture built on content hashing vs. semantic versioning. The assets themselves do not need a cache purge — only the HTML entry point that references them changes.
The Rollback Workflow
Step 1: Retrieve the Prior dist/ Artifact
Your CI pipeline should store the dist/ directory as a build artifact for every successful deploy. In GitHub Actions this means uploading via actions/upload-artifact with a retention period that outlasts your deployment window. In a self-hosted setup it means pushing the dist/ tarball to an S3 bucket or Cloudflare R2 with the git SHA as the key.
Retrieve the prior artifact:
# If stored in S3 (adjust bucket and key prefix to match your pipeline)
PRIOR_SHA="abc1234"
aws s3 cp "s3://your-deploy-bucket/artifacts/${PRIOR_SHA}/dist.tar.gz" /tmp/prior-dist.tar.gz
tar -xzf /tmp/prior-dist.tar.gz -C /tmp/prior-dist
# Verify the manifest is present and parseable
jq -r 'keys | length' /tmp/prior-dist/.vite/manifest.json
If you store artifacts in Cloudflare R2, the same aws s3 CLI works with --endpoint-url pointed at your R2 bucket endpoint.
Step 2: Redeploy the Prior dist/ to Origin
The following shell script redeploys the prior dist/ to S3 or Cloudflare R2, setting appropriate cache headers. Hashed assets receive immutable headers. The HTML entry point and the manifest receive no-cache to ensure downstream validation on every request.
#!/usr/bin/env bash
set -euo pipefail
PRIOR_DIST="${1:?Usage: rollback.sh <path-to-prior-dist>}"
S3_BUCKET="${S3_BUCKET:?Set S3_BUCKET env var}"
CDN_BASE="${CDN_BASE:?Set CDN_BASE env var}" # e.g. https://assets.example.com
# Upload hashed assets first (immutable, long TTL)
aws s3 sync "${PRIOR_DIST}/assets/" "s3://${S3_BUCKET}/assets/" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE \
--exclude "*.html" \
--exclude "manifest.json"
# Upload manifest.json (short TTL, re-validated by servers)
aws s3 cp "${PRIOR_DIST}/.vite/manifest.json" "s3://${S3_BUCKET}/.vite/manifest.json" \
--cache-control "no-cache, no-store, must-revalidate" \
--content-type "application/json"
# Upload HTML entry points last (short TTL)
aws s3 sync "${PRIOR_DIST}/" "s3://${S3_BUCKET}/" \
--cache-control "no-cache, no-store, must-revalidate" \
--exclude "assets/*" \
--exclude ".vite/*" \
--include "*.html"
echo "Redeployment complete. Proceed to CDN purge of HTML entry points."
Upload order matters. Assets must reach origin before HTML is served — otherwise a user who lands during the upload window requests an index.html that references assets not yet available. Uploading assets first, then HTML, collapses the inconsistency window to the HTML upload moment rather than the entire sync duration.
Step 3: Invalidate Only the HTML Entry Point at the CDN
Because hashed asset URLs are immutable and their prior CDN cache entries remain valid, you do not need a full cache purge. You only need to evict the HTML entry point so that the CDN re-fetches the prior index.html from origin.
For Cloudflare, a targeted purge by URL is documented in the Cloudflare cache rules and purge reference. The minimal purge for a single-page Vite app is:
curl -s -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" \
--data "{\"files\": [\"${CDN_BASE}/index.html\", \"${CDN_BASE}/\"]}" \
| jq -r '.success'
If your application uses server-side rendering with multiple HTML entry points (one per route), purge each HTML URL that your template layer generates from the manifest. Hashed JS and CSS URLs do not need to be in this list.
Step 4: Verify All Asset URLs Resolve
After the CDN purge propagates (typically within a few seconds on Cloudflare), verify that every asset the prior manifest references responds with HTTP 200. This one-liner extracts all file values from the manifest and curls each one:
jq -r '.[].file' /tmp/prior-dist/.vite/manifest.json \
| sed "s|^|${CDN_BASE}/|" \
| xargs -P8 -I{} curl -o /dev/null -s -w "%{http_code} %{url_effective}\n" {} \
| sort | uniq -c | sort -rn
Every line should show 200. A 404 means a file that the manifest references is not present on origin — investigate whether the prior artifact was complete before you deployed it. A 304 on an asset URL is unexpected given immutable headers but is harmless. Anything else warrants investigation before you declare the rollback complete.
The broader pattern for verifying fingerprinted assets across CI stages is covered in rolling back fingerprinted assets in CI/CD.
SVG Diagram: Rollback Flow
GitHub Actions Rollback Job
If your pipeline stores artifacts per commit, you can wire a rollback job that pulls the prior artifact and redeploys it without human SSH access. The following snippet assumes the prior successful commit SHA is supplied as a workflow input.
name: Rollback Vite Deploy
on:
workflow_dispatch:
inputs:
prior_sha:
description: "Git SHA of the last known-good build to restore"
required: true
type: string
jobs:
rollback:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
S3_BUCKET: ${{ secrets.S3_DEPLOY_BUCKET }}
CDN_BASE: ${{ secrets.CDN_BASE_URL }}
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
steps:
- name: Download prior dist artifact from S3
run: |
aws s3 cp "s3://${S3_BUCKET}/artifacts/${{ inputs.prior_sha }}/dist.tar.gz" /tmp/prior-dist.tar.gz
mkdir -p /tmp/prior-dist
tar -xzf /tmp/prior-dist.tar.gz -C /tmp/prior-dist
- name: Verify prior manifest is present
run: |
ENTRY_COUNT=$(jq -r 'keys | length' /tmp/prior-dist/.vite/manifest.json)
echo "Prior manifest has ${ENTRY_COUNT} entries"
if [ "${ENTRY_COUNT}" -lt 1 ]; then
echo "ERROR: Manifest is empty or malformed"
exit 1
fi
- name: Sync hashed assets to origin (immutable headers)
run: |
aws s3 sync /tmp/prior-dist/assets/ "s3://${S3_BUCKET}/assets/" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE
- name: Sync manifest and HTML entry points (no-cache)
run: |
aws s3 cp /tmp/prior-dist/.vite/manifest.json \
"s3://${S3_BUCKET}/.vite/manifest.json" \
--cache-control "no-cache, no-store, must-revalidate" \
--content-type "application/json"
aws s3 sync /tmp/prior-dist/ "s3://${S3_BUCKET}/" \
--cache-control "no-cache, no-store, must-revalidate" \
--exclude "assets/*" --exclude ".vite/*" \
--include "*.html"
- name: Purge HTML entry point from CDN
run: |
curl -s -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" \
--data "{\"files\": [\"${CDN_BASE}/index.html\", \"${CDN_BASE}/\"]}" \
| jq -e '.success == true'
- name: Verify all manifest asset URLs return 200
run: |
FAILED=0
while IFS= read -r url; do
STATUS=$(curl -o /dev/null -s -w "%{http_code}" "${url}")
if [ "${STATUS}" != "200" ]; then
echo "FAIL ${STATUS} ${url}"
FAILED=$((FAILED + 1))
else
echo "OK ${STATUS} ${url}"
fi
done < <(jq -r '.[].file' /tmp/prior-dist/.vite/manifest.json | sed "s|^|${CDN_BASE}/|")
if [ "${FAILED}" -gt 0 ]; then
echo "${FAILED} asset(s) failed verification"
exit 1
fi
echo "All assets verified"
This job runs entirely from stored artifacts. No Node.js install, no npm ci, no build step. Recovery time is bounded by S3 download speed and CDN propagation, typically under two minutes for most deploys.
Hash Lengths: 8 Characters vs. Longer
The default Vite hash length exposed via [hash:8] in rollupOptions.output produces 8 hexadecimal characters, giving 4.3 billion possible values per asset. For most single-repository projects this is collision-resistant at realistic asset counts.
In monorepos with hundreds of packages building in parallel, or projects that publish to a shared CDN namespace used by multiple teams, the probability of an accidental hash collision increases when many thousands of unique assets share a prefix space. Increasing to [hash:12] or [hash:16] is low cost — it adds 4 or 8 characters to filenames — and eliminates the concern. If your rollback investigation reveals two files with identical hashes that should be distinct, increase hash length before redeploying. The relationship between hash length and collision resistance is part of the broader comparison in content hashing vs. semantic versioning.
Targeted Verification Command
After completing the rollback steps, run this single command to confirm that every asset the restored manifest references responds with HTTP 200 from the CDN. Replace CDN_BASE with your actual CDN domain.
CDN_BASE="https://assets.example.com"
jq -r '.[].file' /tmp/prior-dist/.vite/manifest.json \
| sed "s|^|${CDN_BASE}/|" \
| xargs -P8 -I{} curl -o /dev/null -s -w "%{http_code} %{url_effective}\n" {} \
| awk '$1 != "200" {fail++; print "FAIL:", $0} END {if (fail > 0) {print fail, "failure(s)"; exit 1} else {print "All assets OK"}}'
A clean rollback prints All assets OK. Any line prefixed with FAIL: identifies a URL still returning a non-200, which means the file was not present in the prior artifact or the sync did not complete.
Frequently Asked Questions
Can I delete the bad build’s hashed files after a rollback?
You can, but you do not need to and doing so adds risk. The bad build’s hashed files are orphaned — no index.html references them anymore, so no browser will request them. They occupy origin storage but cost nothing in terms of user-facing correctness. If storage cost is a concern, schedule a cleanup script to remove hashed assets that no manifest references. Delete HTML files and manifests promptly, but let hashed assets age out on their own schedule.
What if our artifact store only retains the last N builds and the prior good build was further back?
This is the scenario where you have no choice but to rebuild from git. Increase artifact retention on your CI platform to at least 30 days or 20 builds, whichever is longer, so that this scenario does not recur. For the immediate recovery, the rebuild-from-git approach described in “When to Reconsider” below applies.
Does the rollback procedure work for Vite SSR builds?
Yes, with one addition. In SSR mode the server bundle also contains hashed references. You must redeploy both the dist/client/ artifacts and the dist/server/ bundle from the same prior build. Redeploying only the client assets while leaving a new server bundle running creates the same version skew you were trying to fix.
Why does the HTML need to be purged but not the hashed asset files?
Because hashed assets are cached under their full hashed filename, and that filename does not change during a rollback — the prior files were already there. The CDN never evicted them because their Cache-Control: immutable directive told the CDN to keep them indefinitely. Only the HTML entry point needs eviction because it is the one file whose content changed (from pointing at bad hashes to pointing at good hashes) while its URL stayed the same (/index.html has no hash).
When to Reconsider
If the prior artifact is unavailable — artifact store not configured, retention window too short, or the artifact upload step failed silently — you must rebuild from the prior known-good git tag. Do this safely:
- Pin the exact Node.js version used in the original build. Find it in the CI log for that commit (
node --versionoutput orenginesfield inpackage.json). - Use
npm cirather thannpm installto guarantee exactnode_modulesresolution from the lockfile. - Clear any local Vite and Rollup caches before building (
rm -rf node_modules/.vite). - After building, compare the resulting manifest against the one you expect. If any entry point hash differs from what users have cached, you have experienced plugin version drift. At that point you must treat this as a new deploy rather than a true rollback.
# Rebuild from prior git tag with pinned Node
git checkout tags/v1.2.3
node --version # confirm matches original build
npm ci
rm -rf node_modules/.vite dist
npm run build
diff <(jq -r 'to_entries | sort_by(.key) | .[] | .value.file' /tmp/expected-manifest.json) \
<(jq -r 'to_entries | sort_by(.key) | .[] | .value.file' dist/.vite/manifest.json) \
&& echo "Hashes match — safe to deploy" \
|| echo "WARNING: hashes differ — plugin version drift detected"
The fundamental limitation of rebuilding is that you cannot guarantee the output will match what users have cached unless you control every variable in the build environment. Stored artifacts eliminate this uncertainty entirely, which is why retaining them is treated as a prerequisite rather than a nice-to-have.
Related
- Vite Asset Pipeline Configuration — pipeline architecture,
rollupOptions.outputconfiguration, and CDN cache header alignment for Vite production builds - How to Configure Content Hashing in Vite Production Builds — step-by-step configuration of hash tokens, manifest generation, and determinism verification
- Rolling Back Fingerprinted Assets in CI/CD — rollback patterns across build tools and deployment platforms, artifact store strategies, and pipeline integration details
- Cloudflare Cache Rules and Purge — targeted cache purge by URL and cache-tag, rules configuration for immutable assets, and HTML entry point invalidation