The Cached index.html That 404s Your Freshly Deployed App

You ship a new SPA build. The deploy syncs dist/ to object storage with --delete-removed, which cleans out the old hashed chunks. Feels tidy. Then a user who loaded your site five minutes ago — before the deploy — lazily imports a route chunk, and it's gone. White screen. A 404 on a file that existed when their index.html was served.

TL;DR: A cached index.html references hashed chunk filenames from its build. Delete those chunks on deploy and any client still on the old index (plus lagging crawlers like Googlebot) breaks. The fix: sync additively (no --delete-removed) so old chunks survive, then prune separately by filename membership — keep the last 3 builds — never by timestamp.


The Problem

SPA builds emit content-hashed assets: index.abc123.js, vendor.def456.js. index.html hardcodes those exact names. When a browser has index.html cached (or a crawler is mid-crawl), it will request precisely the chunk names from that build.

A destructive sync deletes any file not in the new build. The new index.html points at new hashes — fine for new visitors. But the old index.html in someone's cache points at old hashes that you just deleted. Their next lazy import 404s. The app half-loads and dies.

The Fix, Part 1: Additive Sync

Drop --delete-removed. Old hashed chunks stay put, so a client on a prior build can finish loading the chunks it needs:

# Additive sync — NO --delete-removed. Old hashed chunks are RETAINED so clients
# still on a prior build (and lagging crawlers) can finish loading their chunks.
s3cmd sync grossvideo/dist/ s3://$BUCKET/client/ \
  --acl-public --no-mime-magic --guess-mime-type \
  --add-header="Cache-Control:public, max-age=31536000"

Now nothing a live client depends on disappears mid-session.

The Fix, Part 2: Prune by Membership, Not Time

Additive-only means the bucket grows forever. So a separate, best-effort step prunes — but the how matters. The naive approach, "delete assets older than X," is a trap: s3cmd sync skips unchanged files, so a still-live vendor chunk that hasn't changed in three builds keeps an old last-modified timestamp. A timestamp rule would wrongly delete a chunk that's still referenced.

Prune by filename set membership instead. Keep the union of the current build and the previous two builds' asset lists (last 3 builds), stored as marker files:

# KEEP = current ∪ prev1 ∪ prev2 → prune everything else (>= 3 builds old).
# Membership-based, NOT timestamp-based, so a still-live unchanged chunk is never deleted.
sort -u /tmp/cur.txt /tmp/p1.txt /tmp/p2.txt > /tmp/keep.txt
s3cmd ls --recursive "$PREFIX" | awk '{print $NF}' | while read -r OBJ; do
  REL="${OBJ#$PREFIX}"
  if ! grep -qxF "$REL" /tmp/keep.txt; then
    s3cmd del "$OBJ"
  fi
done

The whole prune step is continue-on-error: true — it's housekeeping, and it must never fail or block a deploy.

The Details That Bite

  • Never poison the markers. If dist/assets/ comes up empty (a broken build), skip pruning and skip rotating the marker files. An empty marker would tell the next deploy "nothing is current" and prune everything live.
  • Keep markers out of client/. The asset-list markers live at the bucket root under .deploy/, outside the served client/ path, and are stored private. Anything outside client/ falls back to the SPA shell when requested, so the marker content is never served — and the direct storage URL 403s.
  • Match keys exactly. Compute the current asset list relative to dist/assets/ (recursive, robust to nested output) so it matches the bucket key suffix exactly. A mismatch would mis-delete nested assets as "not current."

Why Not Just Cache index.html Forever?

The tempting shortcut is to make index.html uncacheable and cache only the hashed assets. That helps — a client always fetches a fresh index — but it doesn't fully solve it. A user who loaded the page before your deploy is already running the old index in memory; they never re-fetch it until they reload. Single-page apps can run for hours on one page load, lazy-importing route chunks the whole time. So "the next load gets the new index" doesn't help the session that's already open. Retaining old chunks for a few builds is what covers that in-flight session — and, incidentally, the crawler that's slowly working through your site with a snapshot of an older index.

How Many Builds to Keep

Three builds is a pragmatic default, not a law. The right number is "long enough that no live session is still referencing chunks you've deleted." If your users keep tabs open for days, keep more; if sessions are short and you deploy rarely, fewer is fine. The cost of keeping too many is a slightly larger bucket (cheap); the cost of keeping too few is a white screen for someone mid-session (expensive). When in doubt, err toward keeping more — object storage is far cheaper than a broken app. Just make sure the pruning is driven by build membership, so "keep N builds" actually means N builds and not "N days," which — as we learned — deletes live files that happened not to change.

Lessons Learned

  • A cached index.html is a contract for old filenames. Deploys that delete old hashed chunks break every client still holding the old index. Sync additively.
  • Prune by membership, not timestamp. Incremental sync preserves old mtimes on unchanged-but-live files. Only the filename set tells you what's truly superseded.
  • Housekeeping must never block a deploy. Best-effort, fail-open, and never let an empty build poison the state that drives the next prune.

Ever white-screened users with a too-aggressive deploy cleanup? Commiserate below.

Building jo4.io - a URL shortener with a SPA dashboard served from the edge.