- date
- entry
- 005
- topic
- infrastructure
- rev
- —
Why sync --delete blanks the screen mid-deploy
This one has a shelf life. Anyone who loads the page after the deploy is fine; anyone who already had it open goes blank — and the error the browser hands you blames MIME types, three layers away from the actual cause.
A user reports a white screen. The console says:
Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html". Strict MIME type
checking is enforced for module scripts per HTML spec.
Or the other variant:
Uncaught (in promise) TypeError: Failed to fetch dynamically imported module:
https://app.example.com/assets/SomeChunk-A1b2C3.js
Both complain that the thing they received is the wrong type. What actually happened is that the file was there thirty seconds ago and your deploy script deleted it.
The timeline
Two individually ordinary settings are required: the deploy runs
aws s3 sync dist/ s3://bucket --delete, and CloudFront rewrites origin 403/404 responses
into index.html with a 200 — which is what makes SPA client-side routing work at all.
T+0:00 User opens the page
The HTML references /assets/index-A.js, which loads fine
T+0:30 You push, CI deploys
New build hash is B
sync --delete removes index-A.js, uploads index.html and index-B.js
CloudFront invalidation on /*
T+0:35 User clicks a button that triggers a dynamic import
The app running in their browser is still the old one; it knows hash A
GET /assets/index-A.js
→ S3 returns NoSuchKey
→ CloudFront's error rule swaps in index.html with a 200
→ the browser receives Content-Type: text/html
→ strict module MIME checking rejects it → white screen
The message talks about MIME types because that is the last link in the chain. The browser only knows it asked for a JS module and got HTML. It cannot see the two rewrites in front of it.
This is the second side effect of
that same SPA fallback rule. Last time it turned
/blog/ into the homepage; this time it disguises "file does not exist" as "file is HTML".
Two curls confirm it
First, ask the live HTML which hash it currently references:
curl -s https://app.example.com/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js'
Then ask what that file's content type is:
curl -sI "https://app.example.com/assets/index-A1b2C3.js" | head -3
# healthy: content-type: text/javascript
# broken: content-type: text/html ← the fallback fired; that key is gone
A text/html on the second one settles it. To confirm the deploy caused it, check whether
CI is mid-run, and look at the sync line in the workflow:
grep 's3 sync' .github/workflows/deploy.yml
A hard refresh is not a fix. It works — refetching index.html gets you the new hash — but it asks every currently-active user to repair the problem themselves. And if the edge node has not picked up the invalidation yet, the refresh serves the old HTML anyway and nothing improves.
The fix: drop --delete
Vite and CRA production filenames carry a content hash, which makes them immutable by construction — change the content and you get a different filename. So old files are never overwritten and never misused. Their only cost is S3 storage.
- name: Deploy to S3
run: |
# no --delete: old bundles stay as a grace period for open sessions
aws s3 sync dist/ s3://$BUCKET
index.html keeps its name, so it is still overwritten and new visitors always get the
new build. The only thing that changes is that old hashes remain fetchable.
Let an S3 lifecycle rule clean up, rather than a script:
{
"Rules": [{
"ID": "expire-old-asset-bundles",
"Status": "Enabled",
"Filter": {"Prefix": "assets/"},
"Expiration": {"Days": 60}
}]
}
aws s3api put-bucket-lifecycle-configuration \
--bucket "$BUCKET" --lifecycle-configuration file://lifecycle.json
Sixty days is the number I use, and there is no science in it beyond being far longer than anyone leaves a tab open. What matters is that the deletion is driven by elapsed time rather than by "files this build did not produce" — that second criterion was the wrong test from the start.
Why --delete ends up in the script
Because it looks correct. "Sync this directory to that bucket" carries an intuition that the two sides should match and anything extra is garbage. That holds when you are syncing data. It does not hold when you are syncing published assets — an older bundle is not garbage, it is something still being referenced.
The other option is to change CloudFront so /assets/* uses a cache behavior without the
error rewrite, letting old hashes return an honest 404 the frontend can catch. That works, but it is
one more piece of config to maintain and it only upgrades the white screen to a clearer error.
Removing --delete makes the situation stop happening.
What not to do
- Tell users to hit Ctrl+Shift+R. Everyone online during a deploy hits this; that is not a fix, it is passing the cost along.
- Run
aws s3 rm assets/to reclaim space. That severs every active session at once — worse than a deploy. - Remove the SPA fallback entirely. Now
/some/routegenuinely 404s, and you have traded down.
If you remember one thing
A deploy is an interval, not an instant. During that interval two versions of the app exist at once: the new one on the server, and the old one still running in people's browsers. The old one keeps making requests, so the files it needs have to outlive it by a while. Delete them too early and what breaks is not your deploy — it is whatever someone else was in the middle of doing.
Revision history
- First published