Rolling Back esbuild Fingerprinted Assets After a Bad Deploy
A bad deploy with fingerprinted assets is worse than a bad deploy with static filenames. When something breaks in production, you cannot simply overwrite main.js — the CDN cached main-a1b2c3d4.js at that exact URL for up to a year. The HTML entry point already references that hash. Your users are stuck until you either fix forward or restore the exact prior artifacts under their original hashed filenames.
This guide is an emergency response playbook: detect the bad deploy, use the esbuild metafile to identify prior hashed outputs, redeploy those prior artifacts, update the HTML entry point, and verify the CDN is serving the correct version. Every step is runnable as written.
Before starting, make sure you understand how the esbuild metafile drives fingerprinting workflows — specifically that the outputs map is the authoritative record of which content hash was embedded in each filename. If you are also coordinating a CDN purge as part of this rollback, the Cloudflare cache rules and purge guide covers targeted URL invalidation. For rollbacks managed inside a CI/CD pipeline, see rolling back fingerprinted assets in CI/CD.
How the esbuild Metafile Enables Rollback
When you set metafile: true in your esbuild config, esbuild writes a JSON structure that maps every output filename to its origin. The esbuild 0.20+ metafile structure looks like this:
{
"inputs": {
"src/index.js": { "bytes": 1234, "imports": [] }
},
"outputs": {
"dist/index-a1b2c3d4.js": {
"imports": [],
"exports": [],
"entryPoint": "src/index.js",
"bytes": 45678
},
"dist/assets/logo-9c0d1e2f.png": {
"imports": [],
"bytes": 8192
}
}
}
Three fields matter for rollback:
outputskeys — the exact output paths, including the embedded hash. These are the filenames you must redeploy.entryPoint— present only on chunks that map to a named entry; use this to match each output back to its source file when regenerating HTML references.bytes— the artifact size. When you suspect artifact corruption, compare thebytesvalue from the prior metafile against the actual file size on disk or in your artifact store to confirm you have an unmodified copy.
The rollback strategy depends entirely on finding the prior metafile. If your CI/CD pipeline archives build artifacts — including meta.json — alongside the compiled assets, you can read the prior metafile, reconstruct the exact prior filename list, redeploy those files, and swap the HTML entry point. If artifacts were not preserved, the only option is a full rebuild from the prior commit, which takes longer but produces deterministic outputs identical to the original deploy when your build is correctly configured.
Decision Matrix: Redeploy Prior Artifacts vs. Full Rebuild vs. CDN URL Swap
| Approach | When to use | Speed | Risk |
|---|---|---|---|
| Redeploy prior artifacts | Artifacts preserved in CI store or S3 bucket | Fast (minutes) | Low — exact same bytes, no rebuild needed |
| Full rebuild from prior commit | No artifact store; build is deterministic | Medium (build time + deploy time) | Low if build is deterministic; High if build is non-deterministic |
| CDN URL swap only | Static origin with path aliasing support | Very fast | Medium — depends on CDN alias propagation; does not fix origin |
| Fix forward | Bug is small and easily patched | Medium | Low for trivial fixes; increases if the fix introduces new risk |
| Revert HTML only | Assets are fine; only entry-point HTML changed | Fast | Low — valid when bad deploy only touched index.html |
Choose “redeploy prior artifacts” first if your pipeline archives build outputs. It is the most reliable path because it reuses exact bytes without a compiler invocation. Choose “full rebuild from prior commit” when you trust your deterministic build outputs and have no artifact store. Avoid CDN URL swap except as a temporary stopgap — it does not repair the origin and leaves a configuration inconsistency.
Step 1 — Locate the Prior Metafile
Your CI/CD pipeline should archive dist/meta.json after every successful deploy. If it does, retrieve the prior build’s metafile from your artifact store. If you use GitHub Actions with artifact upload, the artifact will be named and versioned per run.
If the metafile was not archived separately, check whether the entire dist/ directory was archived — the metafile is typically included. As a last resort, if you store build outputs in an S3-compatible store keyed by git SHA, retrieve the prior SHA’s objects directly.
# If using GitHub Actions artifacts (via gh CLI):
gh run list --limit 10 --json databaseId,headBranch,conclusion,displayTitle
# Find the last successful run before the bad deploy, then:
gh run download <PRIOR_RUN_ID> --name build-artifacts --dir /tmp/prior-build
# Confirm the metafile is present:
ls /tmp/prior-build/dist/meta.json
Once you have the prior meta.json, you have everything needed to reconstruct the exact filename list.
Step 2 — Extract Prior Hashed Filenames from the Metafile
Parse the outputs object to build two lists: entry-point outputs (those with an entryPoint field — needed to regenerate the HTML reference) and all other outputs (assets, CSS, lazy chunks — needed for upload).
// scripts/list-prior-assets.mjs
// Usage: node scripts/list-prior-assets.mjs /tmp/prior-build/dist/meta.json
import fs from 'fs';
import path from 'path';
const metaPath = process.argv[2];
if (!metaPath) {
console.error('Usage: node list-prior-assets.mjs <path-to-meta.json>');
process.exit(1);
}
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const entryOutputs = {};
const allOutputs = [];
for (const [outputPath, info] of Object.entries(meta.outputs)) {
const filename = path.basename(outputPath);
allOutputs.push({ outputPath, filename, bytes: info.bytes });
if (info.entryPoint) {
const src = path.basename(info.entryPoint);
entryOutputs[src] = { outputPath, filename };
}
}
console.log('\n=== Entry outputs (used in HTML) ===');
for (const [src, out] of Object.entries(entryOutputs)) {
console.log(` ${src} → ${out.filename} (${out.outputPath})`);
}
console.log('\n=== All outputs (upload these) ===');
for (const asset of allOutputs) {
console.log(` ${asset.outputPath} (${asset.bytes} bytes)`);
}
Running this script against the prior metafile prints exactly which hashed filenames need to be present on the origin and which filename the HTML must reference. For example:
=== Entry outputs (used in HTML) ===
index.js → index-a1b2c3d4.js (dist/index-a1b2c3d4.js)
=== All outputs (upload these) ===
dist/index-a1b2c3d4.js (45678 bytes)
dist/assets/logo-9c0d1e2f.png (8192 bytes)
The 8-character hex hash is the default esbuild length. If your monorepo uses thousands of chunks, extend to 12 or 16 characters via entryNames: '[name]-[hash:12]' to reduce collision probability — but do not change hash length between builds, as this changes every output filename.
Step 3 — Redeploy Prior Artifacts
Upload the prior artifacts from your artifact store to the origin. The exact command depends on your storage backend.
# Example: sync prior dist/ to S3-compatible origin
# Assumes /tmp/prior-build/dist/ contains the restored artifacts
aws s3 sync /tmp/prior-build/dist/ s3://your-bucket/assets/ \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*.html" \
--exclude "meta.json"
# Deploy the prior index.html last (no immutable cache — it must be revalidated)
aws s3 cp /tmp/prior-build/dist/index.html s3://your-bucket/ \
--cache-control "public, max-age=0, must-revalidate" \
--content-type "text/html; charset=utf-8"
Deploying the HTML last is critical. If you upload the HTML first and the assets are still in transit, users who load the page during that window receive a new HTML pointing at assets that may not exist yet on the CDN edge — the same race condition you are trying to escape. See integrating esbuild with CDN fingerprinting workflows for full sequencing rules.
After uploading, purge only the HTML entry point from the CDN. Because hashed asset URLs are immutable, the CDN’s cached copies of index-a1b2c3d4.js are permanently valid — they do not need purging. Only the mutable index.html URL needs invalidation so edges pull the prior version. Details on scoped purge calls are in the Cloudflare cache purge guide and the Cloudflare bad-deploy rollback walkthrough.
Verification
Confirm the CDN edge is now serving the prior hashed assets and that index.html references the correct filenames. Run all three checks before closing the incident.
# 1. Confirm the prior hashed JS is served with correct headers
curl -sI "https://cdn.example.com/assets/index-a1b2c3d4.js" \
| grep -iE "^(http|content-length|cache-control|cf-cache-status|age):"
# 2. Confirm the bad deploy's hash is NOT referenced in the live HTML
curl -s "https://example.com/" | grep -oE '[a-f0-9]{8}\.js' | sort -u
# 3. Confirm the prior hash IS referenced in the live HTML
curl -s "https://example.com/" | grep "index-a1b2c3d4.js"
Expected output for check 1:
HTTP/2 200
content-length: 45678
cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
If cf-cache-status is MISS immediately after the purge, that is expected — the edge is pulling from origin. Repeat the request: subsequent responses should be HIT. If content-length does not match the bytes value from the prior metafile, the wrong artifact version was uploaded.
What if the prior hashed asset returns a 404?
The artifact was not uploaded successfully, or the S3/origin path does not match the output path in the metafile. Re-run the sync command and confirm the destination key prefix matches the path esbuild wrote — the metafile key dist/index-a1b2c3d4.js becomes assets/index-a1b2c3d4.js on S3 only if your sync strips the dist/ prefix.
What if the CDN still serves the bad version after purging HTML?
Some CDNs have regional propagation lag. Wait 30–60 seconds and retry from a different geographic location using a VPN or a remote curl request. If the bad version persists beyond 5 minutes after a confirmed purge, check that the purge targeted the exact URL including protocol and subdomain — https://example.com/ not http://example.com/ or https://www.example.com/.
When to Reconsider: When a Full Rebuild Beats Redeploying Prior Artifacts
Redeploying prior artifacts is fast and low-risk when the artifacts themselves are intact and were produced by a deterministic build. There are four situations where a full rebuild from the prior commit is the better choice:
-
Non-deterministic build outputs. If your build injects a timestamp, reads environment-specific secrets into the bundle, or has non-deterministic chunk splitting, rebuilding from the same commit will produce different hashes than the original deploy. In this case, neither approach restores the original filenames — you need to fix the non-determinism first. See the guide on deterministic build outputs.
-
Corrupted artifacts. If the artifact store itself is suspect — an incomplete upload, a storage failure, or accidental mutation — do not redeploy from it. Rebuild from the commit.
-
The bad deploy only touched source code, not build config. If a developer pushed broken application logic but the build pipeline itself is fine, rebuilding from the prior commit (before the bad change) produces clean artifacts. This is conceptually the same path as redeploying artifacts but does not require a prior artifact store.
-
Security incident. If the bad deploy included a compromised dependency or a supply-chain artifact, prior artifacts from the same period may also be compromised. Rebuild from a clean environment using pinned, audited dependencies.
Frequently Asked Questions
Do I need to purge the CDN for every hashed asset when rolling back?
No. Hashed assets are immutable by design — their URL encodes their content. If the prior asset index-a1b2c3d4.js was previously cached by the CDN, that cached copy is correct. You only need to purge the HTML entry point (index.html or equivalent) so the CDN pulls the updated HTML that references the prior hashes.
What if my esbuild build does not write a metafile?
Add metafile: true to your build config immediately and archive the output. Without it, the only source of truth for which hash corresponds to which source file is the filenames themselves — which you can grep from a preserved dist/ snapshot. Going forward, treat the metafile as a first-class deploy artifact alongside the compiled files.
Can I use the bytes field in the metafile to verify artifact integrity?
Yes. The bytes field in each output entry records the exact file size esbuild produced. After redeploying, compare the actual file sizes against the metafile values. A mismatch indicates corruption, a partial upload, or an encoding difference (e.g., the file was re-compressed in transit). It is not a cryptographic check, but it catches the most common artifact integrity failures quickly.
How long should I keep prior build artifacts?
Retain at least the three prior successful builds. In most CI/CD systems this means configuring artifact retention to keep the last 5–10 runs. The cost is minimal — a typical frontend bundle with sourcemaps is under 10 MB compressed — and it eliminates the need for a full rebuild during the most time-sensitive part of an incident.
Related
- esbuild Fingerprinting Plugins — parent guide covering plugin architecture, native
[hash]configuration, and metafile generation - Integrating esbuild with CDN fingerprinting workflows — stale cache diagnosis and targeted purge sequencing for esbuild deploys
- Rolling back fingerprinted assets in CI/CD — pipeline-level rollback patterns, artifact retention strategy, and automated revert gates
- Rolling back a bad asset deploy on Cloudflare — CDN-side purge commands and cache rule configuration for Cloudflare-hosted assets
- Deterministic build outputs — why build reproducibility is a prerequisite for reliable rollbacks