Keeping Old Assets Alive After Deploy
Static sites with content-hashed assets have a subtle failure mode at every deploy. A reader opened a documentation page an hour ago; it loaded app.3f9a1c2e.js. Since then, the site was deployed and the new build references app.8b21d7f4.js instead. Now the reader opens the search dialog, which lazily imports search.5c0e19a3.js — a chunk from the old build. If the deploy removed old files, that request returns 404 and search silently stops working. Nothing is wrong with the new build and every test passes; the failure exists only in the gap between versions, for readers with tabs open across a deploy.
This guide measures that gap on a documentation site, keeps previous builds' hashed assets available for a week on both a managed host and a self-hosted S3 setup, adds a client-side recovery for chunk load failures, and cleans up old files without breaking anyone. It is part of Rollbacks and Deploy Safety for Static Sites, and relies on Cache Busting with Content-Hashed Filenames.
Prerequisites
- A static site with content-hashed assets and some lazily loaded JavaScript (islands, search, dialogs).
- Error tracking or CDN logs that show 404s for asset paths — see Real-User Error Tracking on Static Sites.
- Control over the deploy step and the asset storage.
Step 1: Measure the Problem
The site deployed about four times a day. Error tracking and CDN logs over two weeks showed:
| Measure | Value |
|---|---|
404s for /_astro/*.js and *.css per day | ~1,900 |
| Share coming from pages loaded before the latest deploy | 97% |
| Readers affected (sessions with a stale chunk 404) | ~0.9% of sessions |
| Most affected feature | search dialog (lazy chunk) |
| Median age of the stale page at the time of the 404 | 3.1 hours |
Almost one session in a hundred hit a broken feature after a deploy. Documentation readers often keep tabs open for hours while working, which made the problem worse than on a typical marketing site.
A retention period of seven days would cover more than 99% of requests; one day would cover about 92%.
Step 2: Keep Old Assets on a Self-Hosted Setup
On S3, the fix is simply not deleting old hashed files on deploy. Sync without --delete for the assets directory, and delete HTML-only files that no longer exist separately:
# assets: add new, never delete during deploy
aws s3 sync dist/_astro/ s3://docs-bucket/_astro/ --only-show-errors --cache-control 'public, max-age=31536000, immutable'
# HTML and other files: normal sync with delete, excluding the assets directory
aws s3 sync dist/ s3://docs-bucket/ --delete --exclude '_astro/*' --only-show-errors
Old chunks accumulate, so cleanup runs separately (Step 5). For blue-green setups, the same applies: the assets directory is shared between colours, as in Blue-Green Deploys for Static Sites.
Step 3: Keep Old Assets on a Managed Host
Atomic deploys on managed hosts serve only the files in the current deployment. Three approaches work:
- Platform features. Vercel's skew protection routes requests from old pages to the deployment they came from, for a configurable period. Where available, it is the simplest option.
- Carry previous assets forward. Before deploying, download the previous deployment's
_astro/directory (kept as a CI artifact or in a bucket) and merge it intodist/_astro/, keeping files up to seven days old. The new deployment then contains both old and new chunks. On this site that added about 4 MB per deploy, well within limits. - Serve assets from separate storage. Upload hashed assets to a bucket on an assets hostname that never deletes them, and set the build's asset base URL to it. HTML deploys atomically on the host; assets live independently.
The site used the second approach on Cloudflare Workers static assets: a manifest records when each asset file was first deployed, and the merge step keeps any file younger than seven days.
// scripts/carry-assets.mjs — merge recent previous assets into this build
const manifest = JSON.parse(await readFile('asset-history.json', 'utf8').catch(() => '{}'));
const now = Date.now(), WEEK = 7 * 864e5;
for (const f of await globby('dist/_astro/*')) manifest[basename(f)] ??= now; // record new files
for (const [name, first] of Object.entries(manifest)) {
if (now - first > WEEK) { delete manifest[name]; continue; }
if (!existsSync(`dist/_astro/${name}`)) await copyFile(`prev-assets/${name}`, `dist/_astro/${name}`);
}
await writeFile('asset-history.json', JSON.stringify(manifest));
Step 4: Recover in the Browser Anyway
Retention covers almost everyone, but not a tab left open for two weeks, and not a deploy that removed a chunk by mistake. The dynamic imports that load optional features catch failures and reload once:
async function loadSearch() {
try {
return await import('./search.js');
} catch (err) {
if (!sessionStorage.getItem('reloaded-for-chunk')) {
sessionStorage.setItem('reloaded-for-chunk', '1');
location.reload(); // new HTML references the current chunks
}
throw err;
}
}
The reload happens in response to the reader's action (opening search), so it feels like the feature took a moment to open rather than a random page refresh. The session flag prevents a loop if the chunk is genuinely broken. Vite and Astro also emit a vite:preloadError event on window for failed module preloads, which can trigger the same handler globally.
Step 5: Clean Up Old Files Safely
On S3, a scheduled job deletes hashed assets that no current HTML references and that were last deployed more than seven days ago. The LastModified of each object marks when it was uploaded; since hashed files never change, that is also when it first appeared:
aws s3api list-objects-v2 --bucket docs-bucket --prefix _astro/ --query 'Contents[].[Key,LastModified]' --output text \
| while read -r key modified; do
[ "$(date -d "$modified" +%s)" -lt "$(date -d '7 days ago' +%s)" ] || continue
grep -q "$(basename "$key")" referenced-assets.txt && continue # still used by the live build
aws s3 rm "s3://docs-bucket/$key" --only-show-errors
done
referenced-assets.txt is generated from the live build's HTML and CSS. The bucket's size stabilised at about 1.1× the size of a single build.
Service workers deserve a mention here: a site with an offline service worker that precaches the old build's chunks is immune to this problem for readers who have it installed, but introduces a longer-lived version of it — the cached old HTML itself. If a site uses a service worker, its update flow should prompt or reload on the next navigation after a new version activates, and the same seven-day asset retention protects the requests it makes in between.
Measured Impact
| Measure | Delete on deploy | 7-day retention + reload fallback |
|---|---|---|
| Missing-chunk 404s per day | ~1,900 | ~12 |
| Sessions with a broken feature after a deploy | 0.9% | < 0.01% |
| Search dialog failure reports per month | 14 | 0 |
| Extra storage | — | ~10% of one build |
| Deploy size increase (managed host) | — | ~4 MB |
Pitfalls & Rollback
--deleteon the assets directory. Removes chunks that open tabs still need.- Retention without cleanup. Storage grows forever; delete unreferenced files after the window.
- Reload loops. Guard the fallback with a session flag.
- Reloading at random moments. Tie the reload to a user action so readers do not lose their place unexpectedly.
- Rollback: retention is additive; turning it off returns to deleting old files, and the reload fallback can remain as a safety net.
Conclusion
Content hashing makes caching safe; it also means every deploy invalidates the chunk names that open pages will ask for later. Keep previous hashed assets for about a week — by not deleting them on self-hosted storage, or by carrying them into atomic deployments on managed hosts — add a one-time reload when a dynamic import still fails, and clean up unreferenced files after the window. On this site that took stale-chunk failures from about one session in a hundred to effectively none.
FAQ
Why do pages break for readers after a deploy?
A page that was loaded before the deploy references hashed JavaScript and CSS chunks from the old build. If it later requests a chunk that was only loaded on demand, such as a lazily imported search module, and the deploy removed old files, the request returns 404 and that feature breaks until the reader reloads.
Do managed hosts keep old assets?
Atomic deploys on most managed hosts serve only the files in the current deployment, so old hashed chunks disappear at the moment of deploy. Some platforms offer skew protection or asset retention, but it is not the default everywhere, and self-hosted setups that sync with delete remove old files immediately.
How long should old assets be kept?
Long enough to cover most open tabs, which on the site measured here meant seven days covered over 99 percent of stale-chunk requests. Content-hashed files are small and immutable, so keeping them for a week or a month costs very little.
What should the page do if a chunk still fails to load?
Catch the dynamic import error and reload the page once, preferably after the reader's action rather than at a random moment, so the reader gets the new version with all its chunks. Guard against reload loops with a session flag.
Related
- Parent: Rollbacks and Deploy Safety for Static Sites — safe deploys overall.
- Atomic Deploys vs Incremental Uploads — how deploys replace files.
- Code Splitting and Module Preload in Vite-Based SSGs — where lazy chunks come from.
- Deploying Only Changed Files — deletion timing in changed-file deploys.
- Real-User Error Tracking on Static Sites — how the problem was found.
Filed underFixes