Rolling Back Webpack Asset Hashes After a Bad Deploy

A broken deploy ships new JS or CSS under new hashed URLs, updates the HTML entry point to reference those new hashes, and immediately the CDN begins serving the new HTML to fresh visitors — who now get broken assets. The old hashed files are still present on the CDN origin under their old URLs, untouched and fully cached. The fix is not a cache purge. The fix is to redeploy the previous dist/ artifacts and swap the HTML entry point back to the previous manifest’s hashed filenames.

This guide covers the complete rollback procedure, from identifying the prior good build artifacts through verifying that live users are hitting the restored asset URLs. For the configuration changes that prevent this failure mode, see the Webpack Output Hashing Setup reference. For rollbacks in CI/CD pipelines more broadly, see rolling back fingerprinted assets in CI/CD.

Why Hashed Assets Make Rollback Safe

Because [contenthash] produces a different filename for every unique file content, hashed asset files are immutable by definition. A file at assets/js/app.3f2a1b4c.js will always contain the same bytes. CDNs treat it as permanently cacheable under Cache-Control: public, max-age=31536000, immutable.

When the bad deploy ran, it uploaded assets/js/app.9a7d2e1f.js (the new broken file) alongside the old assets/js/app.3f2a1b4c.js, which was never deleted. Both files live at the CDN origin simultaneously. A rollback is therefore a two-step operation: redeploy the prior dist/ artifacts to ensure the origin is consistent, then swap index.html back to the old manifest.json so it references the working hashed URLs.

Understanding cache key architecture clarifies why this works: the cache key is the full URL including the hash segment, so the old and new artifacts occupy completely separate cache entries and there is no conflict between them.

Rollback Strategy Decision Matrix

Not every bad deploy requires the same recovery path. Use the matrix below to choose the right strategy before taking action.

Strategy When to use Time to recovery Risk
Redeploy prior dist/ + swap HTML (this guide) Bad JS/CSS logic; prior build artifacts available in CI storage 2–5 minutes Low — immutable assets are never deleted; only HTML changes
CDN path redirect (old URL → working file) Prior artifacts lost; no CI storage 5–15 minutes Medium — requires CDN rule change, may cache the redirect itself
Feature-flag rollback Bad feature is flag-gated; flag service is available < 1 minute Very low — no deploy needed; but only viable if code path is flagged
Cache TTL wait Bad asset is non-critical; TTL is short (minutes) TTL duration Low cost but high user-impact time; not acceptable for critical errors
Full CDN cache purge Security vulnerability in cached asset content 2–10 minutes High — invalidates all edge caches including working assets; causes origin spike

For most broken JavaScript or CSS deploys, redeploy prior dist/ + swap HTML is the fastest and lowest-risk path.

Rollback Timeline

Webpack Asset Rollback Timeline A horizontal timeline with four phases: Good Deploy (green), Bad Deploy (red/orange), Rollback Procedure (blue/purple), and Restored State (green). Each phase shows the key files involved and the CDN state. T=0 Good Deploy app.3f2a1b4c.js styles.7d9e2f0a.css manifest.json → old CDN: working T=1 Bad Deploy app.9a7d2e1f.js ← broken styles.c1a4f8b3.css manifest.json → new CDN: broken T=2 Rollback redeploy old dist/ restore manifest.json swap index.html purge HTML only T=3 Restored HTML refs 3f2a1b4c old assets still cached new bad assets orphaned CDN: working again Webpack Asset Rollback Timeline Old hashed files remain on CDN — only HTML entry point needs to change
Rollback timeline: the bad deploy uploads new hashed files and swaps the HTML manifest reference; rollback redeploys old dist artifacts and restores the HTML to the previous manifest's URLs.

Step-by-Step Rollback Procedure

Step 1 — Identify the Previous Good Build Artifacts

Locate the prior build’s dist/ directory. The canonical source depends on your CI setup:

# GitHub Actions example: download a prior artifact by run ID
# Find the last good run ID from your CI dashboard, then:
gh run download <RUN_ID> --name dist-artifacts --dir /tmp/prior-dist

# GitLab CI example: retrieve a prior job artifact
# (replace PROJECT_ID, JOB_ID with actual values)
curl --location \
  --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
  "https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/jobs/${JOB_ID}/artifacts" \
  --output /tmp/prior-dist.zip
unzip /tmp/prior-dist.zip -d /tmp/prior-dist

# If you use git tags + a dist commit strategy:
git show v1.4.2:dist/manifest.json > /tmp/prior-manifest.json

Confirm the prior manifest exists and contains the expected hashed filenames:

cat /tmp/prior-dist/manifest.json
# Expected output:
# {
#   "app.js": "/assets/js/app.3f2a1b4c.js",
#   "app.css": "/assets/css/app.7d9e2f0a.css",
#   "vendors.js": "/assets/js/vendors.c8e1d5b2.js",
#   "runtime.js": "/assets/js/runtime.a4f6c0e8.js"
# }

Step 2 — Redeploy the Previous dist/ to CDN Origin

Upload the prior dist/ folder to your CDN origin storage. Because hashed filenames are unique, the prior files will coexist with the current (broken) files without conflict. There is no deletion needed.

#!/bin/bash
# redeploy-prior-dist.sh
set -euo pipefail

PRIOR_DIST="${1:-/tmp/prior-dist}"
CDN_BUCKET="${CDN_BUCKET:?CDN_BUCKET env var required}"  # e.g. s3://my-cdn-bucket
CDN_PREFIX="${CDN_PREFIX:-/}"

echo "Uploading prior dist artifacts to CDN origin..."

# AWS S3 / CloudFront example
aws s3 sync "${PRIOR_DIST}/assets" "${CDN_BUCKET}/assets" \
  --exclude "*.html" \
  --exclude "manifest.json" \
  --cache-control "public, max-age=31536000, immutable" \
  --metadata-directive REPLACE

echo "Prior hashed assets uploaded."

# Restore the prior manifest.json (not immutable — short TTL)
aws s3 cp "${PRIOR_DIST}/manifest.json" "${CDN_BUCKET}/manifest.json" \
  --cache-control "no-cache, must-revalidate" \
  --metadata-directive REPLACE

echo "manifest.json restored."

For Cloudflare R2 replace aws s3 with rclone copy or the Wrangler R2 CLI equivalents.

Step 3 — Swap the HTML Entry Point

Update index.html to reference the prior manifest’s hashed URLs. If you use HtmlWebpackPlugin, the simplest approach is to copy the prior index.html from the artifact:

# Copy the prior index.html and deploy it with no-cache headers
aws s3 cp "${PRIOR_DIST}/index.html" "${CDN_BUCKET}/index.html" \
  --cache-control "no-cache, must-revalidate" \
  --content-type "text/html; charset=utf-8" \
  --metadata-directive REPLACE

echo "index.html restored to prior version."

If your HTML is server-rendered and reads manifest.json at request time, restoring manifest.json in Step 2 is sufficient — the server will automatically pick up the old hashed URLs on the next request.

For server-rendered apps that cache the manifest in memory, restart the application server after uploading the prior manifest.json so it re-reads the file:

# Example: restart a Node.js app on a remote host
ssh deploy@app-server "sudo systemctl restart myapp"

Step 4 — Purge Only the HTML Entry Point from CDN

Hashed assets must not be purged. The old hashed files are still valid and cached; the new broken hashed files will simply be orphaned (no HTML references them). Only the HTML entry point needs a cache bust so browsers fetch the restored version immediately.

# Cloudflare: purge a single URL
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" \
  --data '{"files":["https://www.example.com/","https://www.example.com/index.html"]}'

# AWS CloudFront: invalidate the HTML entry point only
aws cloudfront create-invalidation \
  --distribution-id "${CF_DIST_ID}" \
  --paths "/index.html" "/"

For detailed CDN-specific invalidation options, see Cloudflare cache rules and purge and AWS CloudFront invalidation.

Step 5 — Verify the Rollback

Confirm that the live HTML entry point references the old hashed filenames, not the broken new ones:

# Fetch the live index.html and extract all script src and link href values
curl -s "https://www.example.com/" \
  | grep -oP '(?:src|href)="[^"]*\.[a-f0-9]{8}\.[^"]*"'

# Expected output — old hashes matching the prior manifest:
# src="/assets/js/app.3f2a1b4c.js"
# src="/assets/js/vendors.c8e1d5b2.js"
# href="/assets/css/app.7d9e2f0a.css"

# Confirm the old hashed JS file is reachable and carries immutable headers
curl -sI "https://www.example.com/assets/js/app.3f2a1b4c.js" \
  | grep -i "cache-control"
# Expected: cache-control: public, max-age=31536000, immutable

If the script src values still show the broken new hashes, the HTML entry point CDN purge has not propagated yet. Wait 30–60 seconds and retry.

Reading manifest.json to Find Prior Hashed Filenames

If you do not have the prior dist/ directory but you do have the prior manifest.json from your artifact store or a git-tagged release, you can reconstruct the exact filenames to verify CDN availability:

// check-prior-assets.mjs
import fs from 'fs';
import https from 'https';

const manifest = JSON.parse(fs.readFileSync('./prior-manifest.json', 'utf8'));

for (const [name, url] of Object.entries(manifest)) {
  const fullUrl = `https://cdn.example.com${url}`;
  const req = https.request(fullUrl, { method: 'HEAD' }, (res) => {
    const status = res.statusCode;
    const cc = res.headers['cache-control'] ?? 'none';
    console.log(`${status}  ${cc}  ${name}${url}`);
  });
  req.on('error', (e) => console.error(`FAIL ${name}: ${e.message}`));
  req.end();
}

Run with node check-prior-assets.mjs. Expect 200 and immutable for every entry. A 404 means the prior asset was deleted from origin storage (e.g., by a aws s3 sync --delete command) and you cannot roll back to this build without re-running it.

When to Reconsider

The redeploy-prior-dist strategy works for logic errors in JS or CSS. Reconsider it when:

A security vulnerability was shipped in an asset that is now cached everywhere. If the bad asset contains a leaked secret, an XSS vector, or another security issue that should not remain accessible even under an old URL, a full CDN purge of the specific asset URL is warranted. Purge the exact hashed URL (/assets/js/app.9a7d2e1f.js) rather than all assets, and consider rotating any exposed secrets immediately. A targeted purge does not affect other cached assets.

Prior build artifacts were overwritten or deleted from origin storage. If your CDN sync script used --delete or rsync --delete, the prior hashed files are gone from origin. The CDN may still serve them from edge cache for max-age duration, but after TTL expiry users will receive 404s. In this case you must re-run the prior build from source (using the same commit and dependencies) to regenerate identical hashed filenames, which requires deterministic build outputs.

The HTML entry point is embedded in a server-side framework that does not read manifest.json at request time. Some SSR setups compile asset paths at build time into the server binary. Restoring manifest.json has no effect; you must redeploy the prior server binary alongside the prior dist/ assets.

More than one deploy happened after the last known-good build. If two or more broken deploys have shipped, the prior manifest may reference files from an intermediate state, not the last fully working state. Trace the CI history carefully and identify the correct artifact version before proceeding.

Frequently Asked Questions

If old hashed assets are never purged, do they accumulate on origin storage forever?

Yes, unless you explicitly clean them up. This is the expected and correct behavior. Run a periodic cleanup script that compares all hashed filenames in origin storage against the manifest files from the last N releases, and deletes files not referenced by any of them. Always keep at least the previous two manifests’ worth of assets available to support rollback.

Can I roll back by just re-running the prior commit through CI?

Yes, if your builds are deterministic. If optimization.moduleIds: 'deterministic' and realContentHash: true are set, the same source commit always produces the same hashed filenames. Re-running the build is equivalent to restoring the artifacts — but it takes longer. Use artifact restoration for speed; use CI re-run as a fallback when artifacts have expired.

Why should I only purge the HTML entry point and not all the new broken hashed assets?

The broken hashed assets (app.9a7d2e1f.js) will never be referenced again once index.html is restored to the prior manifest. They become orphaned: cached at the edge but never requested. Purging them costs CDN API credits and causes an origin load spike with no user-visible benefit. Leave them to expire naturally, then clean them from origin storage in a scheduled maintenance job.