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:

MeasureValue
404s for /_astro/*.js and *.css per day~1,900
Share coming from pages loaded before the latest deploy97%
Readers affected (sessions with a stale chunk 404)~0.9% of sessions
Most affected featuresearch dialog (lazy chunk)
Median age of the stale page at the time of the 4043.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.

Age of stale pages at the time of a missing-chunk request Vertical bars of the share of missing-chunk requests by the age of the page that made them. Under 1 hour 34 percent. 1 to 6 hours 41 percent. 6 to 24 hours 17 percent. 1 to 7 days 7 percent. Over 7 days under 1 percent. How old were the pages requesting missing chunks? 34% Under 1 h 41% 1 to 6 h 17% 6 to 24 h 7% 1 to 7 days <1% Over 7 days
From two weeks of error reports with the page's build version compared with the deploy log.

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 into dist/_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));
Merging recent previous assets into a new atomic deployment The new build produces its own hashed assets. The previous deployment's assets are downloaded from a CI artifact. An asset history manifest records when each file was first deployed. Files younger than seven days that are missing from the new build are copied in. The deployment then contains new and recent old assets, and files older than seven days drop out. Carrying recent assets into each deploy New build new hashed assets Previous assets from CI artifact History manifest first-deployed dates Merge < 7 days copy missing files Atomic deploy old + new chunks
Adds about 4 MB per deploy on this site; the history manifest is stored alongside the previous assets.

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.

Missing chunk requests before and after asset retention Paired bars. Missing chunk requests per day fell from about 1,900 to about 12 after keeping old assets for seven days and adding a reload fallback. Missing-chunk requests per day Before After Missing-chunk 404s per day ~1,900 ~12
Two weeks before and after; the remaining requests came from tabs older than seven days and all recovered via reload.

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

MeasureDelete on deploy7-day retention + reload fallback
Missing-chunk 404s per day~1,900~12
Sessions with a broken feature after a deploy0.9%< 0.01%
Search dialog failure reports per month140
Extra storage—~10% of one build
Deploy size increase (managed host)—~4 MB

Pitfalls & Rollback

  • --delete on 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.

Filed underFixes