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, bad deploy, the rollback procedure, and the restored state. Each phase lists the files involved and the resulting CDN state. Webpack asset rollback timeline old hashed files stay on the CDN, so only the HTML entry point has to change T=0 Good deploy app.3f2a1b4c.js app.7d9e2f0a.css manifest: release N edge: working T=1 Bad deploy app.9a7d2e1f.js (bad) app.c1a4f8b3.css manifest: release N+1 edge: 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 bad assets orphaned edge: working again
The bad deploy uploads new hashed files and repoints the manifest; the rollback restores the prior artifacts and returns 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.

Note the absence of --delete in that sync command. It is the single flag that determines whether the procedure on this page is available to you at all. With it, the sync makes origin storage an exact mirror of the local dist/ directory, which deletes every artifact from the previous release the moment the new one lands. Without it, releases accumulate and every prior build stays one HTML swap away from being live again.

Origin storage after each sync mode Left panel: a sync without the delete flag leaves both the previous release and the current bad release in origin storage, so the rollback target is still present. Right panel: a sync with the delete flag removes the previous release, so the rollback target is gone and a rebuild is required. What origin storage looks like after each sync mode aws s3 sync (no --delete) app.3f2a1b4c.js (release N, good) app.9a7d2e1f.js (release N+1, bad) rollback target still present Rollback = repoint the HTML, about two minutes aws s3 sync --delete app.3f2a1b4c.js (deleted from origin) app.9a7d2e1f.js (only copy left) rollback target gone from origin Rollback = rebuild from source, ten minutes or more
Fingerprinted filenames never collide, so an additive sync costs only storage — and it is what keeps a two-minute rollback on the table.

Storage growth from an additive sync is bounded in practice: a lifecycle rule that expires objects older than 90 days, combined with a nightly job that never deletes anything referenced by the last five manifests, keeps the bucket flat without ever removing a rollback candidate.

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.

The asymmetry between the two requests in that verification is the whole point of the design, and it is worth tracing end to end. The HTML request goes all the way to origin because it was just purged and carries no-cache. The asset request stops at the edge, because app.3f2a1b4c.js was never purged and its immutable copy is still sitting in the same edge cache it was placed in before the bad deploy ever happened.

Rollback request flow A three-lane sequence. The browser requests index.html; the edge misses because the HTML was purged and revalidates against origin; origin returns the restored HTML referencing the old hashes; the browser then requests the old hashed JS and the edge serves it as a hit without contacting origin. Rollback request flow Browser CDN edge Origin GET /index.html MISS after purge, revalidate restored HTML, old hashes 200, Cache-Control: no-cache GET app.3f2a1b4c.js HIT, never purged, still warm
Only the HTML round-trips to origin after a rollback; the restored asset URLs are served straight from the edge copies that were already there.

That is also why rollback latency is dominated by purge propagation rather than by data transfer. Nothing large moves. Confirm the edge behaviour directly with the CDN’s own cache-status header — cf-cache-status: HIT on Cloudflare, x-cache: Hit from cloudfront on CloudFront, x-cache: HIT on a typical Fastly or Nginx configuration:

# The restored asset should be an edge HIT with no origin fetch
curl -sI "https://www.example.com/assets/js/app.3f2a1b4c.js" \
  | grep -iE 'cf-cache-status|x-cache|age|cache-control'

# The HTML should be a MISS or REVALIDATED on the first request after the purge
curl -sI "https://www.example.com/" \
  | grep -iE 'cf-cache-status|x-cache|cache-control'

A non-zero Age on the asset response is the clearest single signal that the rollback worked as designed: the edge is handing back a copy it has held since before the incident began.

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.