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 servedclient/path, and are stored private. Anything outsideclient/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.