Static Asset Hosting for SPAs: Beyond 'Just Use a CDN'
A practical guide to cache strategy, content-hash naming, blue-green deployments, and CORS configuration for serving single-page application assets at scale.
The Lie of 'Just Throw It on a CDN'
Every tutorial makes it sound easy. Build your React or Vue app, upload the output to S3, put CloudFront in front of it, done. Your SPA is globally distributed and blazing fast.
Except it isn't. Not when your users are stuck on a cached version of main.js from three deploys ago. Not when your CSS loads but the JavaScript it references has been replaced. Not when half your users in Europe see the new version and half in Asia see the old one because CDN edge nodes invalidated at different times.
The gap between "serving static files" and "reliably deploying a single-page application" is filled with caching landmines, CORS surprises, and deployment race conditions that nobody mentions in the getting-started guide. This post covers the non-obvious problems and how to solve them — whether you use raw S3, a managed static asset hosting service, or anything in between.
The Caching Horror Story
Here is a real scenario that happens more often than anyone admits.
A team deploys their SPA on a Friday afternoon. The deploy script uploads new files to S3 and runs a CloudFront invalidation on /*. The team verifies the site looks good and goes home for the weekend.
Monday morning, support tickets start rolling in. Some users see a blank white page. Others see a half-rendered UI with missing styles. A few see the old version entirely. The DevTools console shows 404 errors for JavaScript chunks that no longer exist on the server.
What happened? The sequence of failures:
- The `index.html` was cached by the browser. Users who visited last week had the old HTML cached with a 24-hour
max-age. Their browser never checked for a new version — it just loaded the stale HTML, which referenced old JavaScript files. - The old JavaScript chunks were deleted. The deploy script cleared the S3 bucket before uploading new files. Users loading the cached
index.htmlrequestedmain.abc123.js, which no longer existed. 404. - The CDN invalidation was partial. CloudFront's
/*invalidation is not atomic. Some edge nodes purged in seconds, others took minutes. During the window, some users got the newindex.htmlpointing to new chunk names, while others got a mix of old HTML and new assets — or vice versa. - The `Vary` header was missing. Users behind corporate proxies received a gzipped response cached for a client that sent
Accept-Encoding: gzip, but their browser expected Brotli. The response was garbled.
Every one of these failures is preventable. But you have to understand the caching model first.
Content-Hash Naming: The Foundation of Cache Safety
The single most important thing you can do for static asset reliability is content-hash your filenames. Every modern bundler supports this out of the box.
Instead of main.js, your build outputs main.a3f9b2c1.js. When the code changes, the hash changes, and the filename changes. The old file and the new file can coexist simultaneously — no collision, no ambiguity, no cache confusion.
Webpack configuration:
// webpack.config.js
module.exports = {
output: {
filename: "[name].[contenthash:8].js",
chunkFilename: "[name].[contenthash:8].chunk.js",
assetModuleFilename: "assets/[name].[contenthash:8][ext]",
},
};Vite configuration:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
entryFileNames: "assets/[name].[hash].js",
chunkFileNames: "assets/[name].[hash].js",
assetFileNames: "assets/[name].[hash][extname]",
},
},
},
};The critical rule: every file except `index.html` should have a content hash in its filename. The HTML file is the one entry point that must always be fetched fresh — it is the map that tells the browser which hashed files to load.
This creates a clean separation:
index.html— always revalidated, never cached long-term- Everything else — cached forever, because the filename changes when content changes
If you are using a CDN file hosting service, content-hash naming means the CDN never needs explicit cache invalidation for your assets. New deploys produce new filenames, and old cached files expire naturally.
Cache Headers: Getting the Strategy Right
Cache headers are where most teams get it wrong. The difference between no-cache and no-store is not academic — it determines whether your users see stale content after a deploy.
| Directive | What It Actually Does | Use For |
|---|---|---|
| no-store | Never cache. Always fetch from origin. No disk or memory cache. | Sensitive data, auth tokens. Almost never correct for static assets. |
| no-cache | Cache is allowed, but MUST revalidate with origin before using. Still makes a network request every time (conditional GET). | index.html — ensures the browser always checks for a new version. |
| max-age=31536000, immutable | Cache for 1 year. Never revalidate. The immutable flag tells the browser not to revalidate even on a hard refresh. | Content-hashed assets (JS, CSS, images with hash in filename). |
| max-age=86400 | Cache for 24 hours, then revalidate. | Assets that change occasionally but don't have content hashes. A compromise — not ideal. |
| s-maxage=86400 | Like max-age but only applies to shared caches (CDNs, proxies). Browser ignores it. | When you want CDN caching but shorter browser caching. |
| stale-while-revalidate=604800 | Serve stale content immediately while fetching a fresh copy in the background. The value is the window (in seconds) during which stale content is acceptable. | Non-critical assets where speed matters more than freshness. |
Applying the Cache Strategy
The correct strategy for SPAs:
# index.html — always revalidate
Cache-Control: no-cache
# Hashed assets (main.a3f9b2c1.js, style.d4e5f6.css)
Cache-Control: public, max-age=31536000, immutable
# Unhashed assets (favicon.ico, robots.txt)
Cache-Control: public, max-age=3600Setting headers in S3 + CloudFront:
S3 does not set Cache-Control headers automatically. You must set them per-object at upload time. In your deploy script:
# Upload hashed assets with long cache
aws s3 sync build/assets/ s3://my-bucket/assets/ \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*.html"
# Upload index.html with no-cache
aws s3 cp build/index.html s3://my-bucket/index.html \
--cache-control "no-cache"If you are using a managed static asset hosting platform, check whether it sets cache headers based on file type automatically. Many do — but the defaults are not always correct for SPAs, so verify.
Deployment Strategies: Atomic Deploys and Blue-Green for Static Assets
The most dangerous moment in a static asset deployment is the window between when new files are uploaded and when old files are removed. During that window, some users may receive a new index.html that references assets not yet uploaded, or an old index.html referencing assets that have already been deleted.
The naive deploy (and why it breaks):
# DON'T DO THIS
aws s3 rm s3://my-bucket/ --recursive
aws s3 sync build/ s3://my-bucket/Between the rm and the sync, your site is either partially available or entirely down. Even without the rm, uploading files one by one means there is a window where the new index.html is live but some new chunks have not yet uploaded.
Atomic deploys with versioned directories:
Upload each deploy to a unique, versioned directory. Then atomically switch which directory is served.
# Upload to a versioned path
DEPLOY_ID=$(git rev-parse --short HEAD)
aws s3 sync build/ s3://my-bucket/deploys/${DEPLOY_ID}/
# Update CloudFront origin path to point to the new deploy
# (Use the AWS console, CloudFormation, or Terraform to update
# the origin path on your distribution's origin to /deploys/$DEPLOY_ID)Old versions remain in S3, so users still loading the previous index.html can fetch the old chunks without hitting 404s. Clean up old deploys after a safe window (e.g., 24-48 hours).
Blue-green deployments:
Maintain two S3 prefixes or buckets — "blue" and "green." Deploy to the inactive one, verify it works, then switch the CDN origin. Rolling back is just switching the origin back.
blue/ <-- currently live
index.html
assets/main.abc123.js
green/ <-- deploy new version here
index.html
assets/main.def456.jsAfter verifying green is good, update CloudFront to point at green. Blue stays intact for instant rollback.
Rollback in practice:
Rollback is where most teams discover their deployment strategy is inadequate. If you deleted the old files during deploy, rollback means re-building and re-deploying. With versioned directories or blue-green, rollback is a single origin-path change — typically under 60 seconds.
Multi-region considerations:
If you serve users globally, remember that CDN edge nodes do not invalidate simultaneously. After switching your origin path, some edge locations will continue serving the cached old version until their TTL expires. This is another reason index.html should have no-cache — it forces edge nodes to revalidate on every request, so the origin-path switch takes effect immediately for the entry point.
For teams that want atomic deploys without managing S3 versioning, CDN origin paths, and invalidation workflows, services like files.link handle asset delivery through a managed CDN with unique file URLs per upload — eliminating cache conflicts by design.
CORS Configuration: The Gotchas Developers Hit
If your SPA loads assets from a different origin — fonts from a CDN, API calls to a different subdomain, or images served through a CDN — you will hit CORS issues. These are the most common surprises:
Fonts fail silently. Browsers enforce CORS for font files loaded via @font-face. If your CDN does not return Access-Control-Allow-Origin, fonts load as a blank or fallback. There is no JavaScript error — the font just does not render. Check the Network tab for a CORS error on .woff2 requests.
S3 CORS configuration is per-bucket, not per-object. You configure CORS rules as a JSON document on the bucket:
{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
}CloudFront must forward the `Origin` header. By default, CloudFront strips the Origin request header. Without it, S3 does not return CORS headers in the response. You must add Origin to the CloudFront cache policy's header whitelist, or use a response headers policy to inject Access-Control-Allow-Origin at the CDN level.
The caching + CORS interaction. If your CDN caches a response without CORS headers (because the first request did not include an Origin header — for example, a direct navigation), subsequent cross-origin requests will get the cached non-CORS response and fail. Fix this by including Origin in your cache key, or by using a CloudFront response headers policy to always include CORS headers.
Wildcard origins in production. Using Access-Control-Allow-Origin: * works for public, read-only assets (images, fonts, public JavaScript). But if your requests include credentials (cookies, authorization headers), the wildcard is rejected by browsers. You must echo back the specific requesting origin.
Preflight caching. OPTIONS preflight requests are expensive — they add a round trip before every cross-origin POST/PUT. Set Access-Control-Max-Age to cache preflight responses. The maximum value varies by browser (Chrome caps at 7200 seconds, Firefox at 86400), so 7200 is a safe default for production.
Troubleshooting Guide
| Symptom | Likely Cause | Fix |
|---|---|---|
| Users see old version after deploy | index.html cached by browser or CDN with a long max-age | Set Cache-Control: no-cache on index.html. Invalidate CDN edge cache for /index.html after each deploy. |
| Blank white page after deploy | New index.html references JS chunks that were deleted or not yet uploaded | Use atomic deploys (versioned directories). Never delete old assets until 24-48 hours after deploy. |
| Fonts broken cross-origin | CDN not returning Access-Control-Allow-Origin for .woff2 files | Add Origin to CloudFront cache policy. Configure S3 CORS or use a CloudFront response headers policy. |
| Mixed old and new assets loading | CDN edge nodes invalidating at different times | Use content-hashed filenames so old and new assets coexist. Set no-cache on index.html. |
| CSS loads but looks wrong | Browser loaded cached old CSS with new HTML structure | Ensure CSS filenames include content hashes. Verify index.html is not being cached. |
| API calls fail with CORS errors after deploy | CloudFront cached a response without CORS headers | Include Origin in the CDN cache key. Or use a response headers policy to always inject CORS headers. |
| Service worker serves stale content indefinitely | Service worker cached old assets and is not updating | Implement a service worker update flow with skipWaiting(). Version your service worker file. |
| Images load on direct visit but fail in img tags on another domain | Missing CORS headers when image is requested cross-origin | Add crossorigin attribute to img tags. Ensure CDN returns CORS headers. |
| Deploy looks fine locally but broken in production | Local browser has no cache; production users have stale cache | Test deploys in an incognito window. Check CDN edge cache status (X-Cache header). |
Conclusion: Static Is Not Simple
Serving a single-page application reliably is harder than it looks. The "just use a CDN" advice skips over the caching model, the deployment atomicity problem, the CORS configuration, and the rollback story — all of which matter in production.
The key takeaways:
- Content-hash every asset except index.html. This is the foundation. Without it, every other optimization is fragile.
- Set cache headers deliberately.
no-cacheonindex.html,immutableon hashed assets. Do not accept defaults. - Deploy atomically. Versioned directories or blue-green deployments prevent the window where old HTML references new (or deleted) assets.
- Configure CORS explicitly. Fonts, cross-origin images, and API calls all have different CORS requirements. Test them in production-like conditions, not just localhost.
- Keep old assets around. Users with cached HTML will request old chunks for hours or days. If those files are gone, your app breaks.
If you want to skip the S3 bucket policies, CloudFront cache behaviors, CORS configurations, and deploy scripts, a managed static asset hosting or CDN file hosting service handles the infrastructure so you can focus on building your application. But whether you manage it yourself or use a service, the fundamentals in this guide apply — caching does not care who configured it.