Back in 2019 I watched a routine deploy turn into a 40-minute outage because a single `app.js` file was overwritten mid-traffic. Half our edge nodes were serving the new HTML that referenced the new JavaScript, while the other half still had the old bundle cached — and for a few thousand users, the site was just a white screen. That night taught me you can't treat a CDN like a hard drive; you need proper content versioning if you want deploys you can trust.
The Hidden Risk of Mutable Files on a Distributed Edge
When you deploy without versioning, you're mutating assets in place. You push ` /static/app.js` to origin, then issue a purge and hope every PoP picks up the change at the same time. It never does. CDNs are eventually consistent, and that window where some edges have the old file and some have the new one is where atomicity breaks. I've seen this cause mismatched CSS and JS bundles, broken source maps, and checkout flows that silently failed because the HTML expected a chunk that wasn't there on that particular edge yet.
The core issue is cache identity. If your URL is the same, the CDN has no way to know the bytes changed without revalidating. You can set a short TTL or use `must-revalidate`, but then you're trading correctness for performance and hammering your origin. That’s not really CDN Content Lifecycle Management: From Upload to Expiration and Archival — it’s just hoping the cache expires before users notice the bug. For anything beyond a tiny site, we need a pattern where a URL always means the exact same bytes, forever.
That's what CDN versioning gives you: immutability by default. Instead of overwriting `/app.js`, you publish `/app.a3f7c9e.js` and never modify it again. The old file stays addressable, the new file gets its own address, and the CDN can cache both indefinitely without ever needing a purge. We've moved from an invalidation problem to a routing problem, which is far easier to solve at the edge.
# Mutable (problematic) - requires purge and risks race conditions
/static/app.js -> Cache-Control: public, max-age=3600, must-revalidate
/static/styles.css -> Cache-Control: public, max-age=3600
# Immutable (versioned) - safe to cache forever, no purge needed
/static/app.a3f7c9e2b1.js -> Cache-Control: public, max-age=31536000, immutable
/static/styles.9c2e1f8a4d.css -> Cache-Control: public, max-age=31536000, immutable
/index.html -> Cache-Control: public, max-age=0, must-revalidate
# HTML is the entry point that references the versioned assets
Building Atomic Deploys With Content-Hashed Immutable Assets
An atomic deploy means users either get the entire old version or the entire new version — never a mix. Content versioning is how you make that happen. My team now builds every deploy so that all static assets are content-hashed. The hash is derived from the file contents, so `app.a3f7c9e.js` will only change when the code actually changes. HTML files, which aren't hashed, act as manifests that point to the exact set of assets for that release.
When we ship, we don't overwrite anything. We upload the new hashed assets alongside the old ones to origin storage — S3, GCS, wherever — and then we atomically switch the pointer to the new HTML. That pointer can be a symlink, a versioned directory like `/releases/v2024.10.15/`, or a KV store entry that your edge function reads. Because the HTML itself is never cached for long (or is served with `stale-while-revalidate`), the cutover happens everywhere within seconds. I remember switching one high-traffic e-commerce site to this model; our deploy-related errors dropped to near zero because we stopped purging altogether.
This approach also makes your deployment pipeline deterministic. Your CI can build, hash, upload, and verify all assets before any user sees them. If the build fails, you just don't flip the pointer. And structuring those paths cleanly matters more than people think — a consistent CDN Content Taxonomy: Organizing Assets for Efficient Edge Delivery and Invalidation where `/assets/v/[hash]/` or `/static/[name].[hash].js` is predictable lets you write simple edge rules and keeps your rollback logic trivial.
// Example: Atomic deploy file layout on origin
// Each deploy gets its own immutable directory
/releases/
/a3f7c9e2b1/
index.html // references /assets/app.a3f7c9e2b1.js
app.a3f7c9e2b1.js
styles.9c2e1f8a4d.css
/b8e1d4f6c2/
index.html // references /assets/app.b8e1d4f6c2.js
app.b8e1d4f6c2.js
styles.7a1b9e3c5f.css
// Edge logic (Cloudflare Worker / Fastly VCL concept)
async function fetchAsset(request) {
const version = await KV.get("current_version"); // "b8e1d4f6c2"
// Rewrite request to the versioned path atomically
const url = new URL(request.url);
url.pathname = `/releases/${version}${url.pathname}`;
return fetch(url, { cf: { cacheTtl: 31536000 } });
}
Making Every Release Rollback-Ready Without Edge Purges
If you can deploy atomically, you can roll back atomically — but only if you keep the old assets around. That’s the rollback ready principle. I’ve worked with teams that deleted previous builds to save storage, then needed 15 minutes to rebuild and re-upload when a bad release went out. Don’t do that. Keep at least your last 5-10 releases immutable and available on origin. Storage is cheap; downtime isn’t. With versioned assets, a rollback is just flipping that `current_version` pointer back. No purge, no cache warming, no waiting for 300 PoPs to catch up.
To make this work at scale, you need thoughtful expiration rules. Versioned assets should be immutable with a year-long TTL, while your entry points (HTML, `manifest.json`, service worker) stay short-lived and revalidated. I usually set hashed assets to `max-age=31536000, immutable` and HTML to `max-age=0, s-maxage=60, stale-while-revalidate=600`. This balance gives you great hit ratios without sacrificing freshness, and it aligns perfectly with setting CDN Content Expiration Policies: Setting Freshness Rules That Match Content Dynamics based on how often the content actually changes rather than using one global TTL.
We audited three different patterns across 12 production properties last year to see how they behaved under real load. The difference in operational reliability was stark, especially when we measured rollback speed and cache efficiency during incidents.
| Versioning Pattern | Avg. Rollback Time | Cache Hit Ratio | Invalidations / Deploy | Storage Overhead |
|---|---|---|---|---|
| Overwrite + Purge (`/app.js`) | 8-14 minutes | 87.2% | 120-800 purge calls | 1x |
| Query String (`/app.js?v=123`) | 3-5 minutes | 91.5% | 0 (but 18% cache misses due to key normalization) | 1.1x |
| Filename Hash (`/app.[hash].js`) | ~45 seconds | 98.6% | 0 | 2.3x (last 10 versions) |
| Versioned Directory (`/v/1.2.3/app.js`) | ~50 seconds | 98.1% | 0 | 2.5x (last 10 versions) |
Putting It Into Practice: From Hashed Builds to Edge Configuration
You've got the strategy — now let's wire it up. In a real pipeline, you don't want developers manually versioning files. Your build tool should do it for you. Whether you're on Vite, Webpack, esbuild, or Next.js, the goal's the same: every file that can be hashed, should be hashed. Your bundler emits something like app.9b3f2a1c.js instead of app.js, and generates a manifest or updated HTML that points to those new filenames. That HTML file becomes your atomic pointer — it's the only mutable file in the entire release.
Where teams trip up is treating all files with the same cache policy. Don't. You need two distinct Cache-Control strategies. Your immutable, content-hashed assets — the JS, CSS, WOFF2, images — get a long, aggressive TTL. We're talking public, max-age=31536000, immutable — a full year. You'll never need to purge them because you'll never overwrite them. Your entry points — index.html, app.json, or your service manifest — get a short TTL. I've found max-age=60 with must-revalidate is the sweet spot for most apps, or max-age=0 if you're deploying 20+ times a day. This split is what makes rollbacks instant; you're only waiting up to 60 seconds for edges to pick up the pointer change, not 24 hours for stale chunks to expire.
The deploy itself has to be ordered, or you'll create a race. First, upload all new immutable assets to your origin — S3, R2, GCS, whatever. Don't make them live yet. Second, verify they're reachable and checksummed. Only then, as the final step, atomically swap the pointer file. If you're using S3 + CloudFront, that means syncing to /releases/v2.4.1/ and then copying index.html to the root. If you're on a pull-through CDN, push to origin and then update the HTML. If that last step fails, your users just stay on the old, perfectly functional version. No broken 404s, no half-deployed app shell.
# Example: CDN-aware cache separation (Nginx / Cloudflare / Fastly VCL logic)
# 1. Immutable, content-hashed assets - cache for 1 year, never revalidate
# Matches app.a3f9c1e2.js, styles.7b2d4f9a.css
location ~* \.[a-f0-9]{8,}\.(js|css|woff2|png|jpg|webp)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header CDN-Cache-Control "public, max-age=31536000, immutable";
# No purge needed. Ever.
}
# 2. Version pointer (entry HTML) - short TTL, always revalidate
location = /index.html {
add_header Cache-Control "public, max-age=60, must-revalidate";
add_header CDN-Cache-Control "public, max-age=15, stale-while-revalidate=30";
add_header Vary "Accept-Encoding";
}
# Deploy Order:
# $ npm run build # -> dist/assets/app.9b3f2a1c.js + dist/index.html
# $ aws s3 sync dist/assets s3://my-app-origin/releases/v2025.10.4/assets/ --cache-control "public, max-age=31536000, immutable"
# $ aws s3 cp dist/index.html s3://my-app-origin/index.html --cache-control "public, max-age=60, must-revalidate" --metadata-directive REPLACE
Advanced Patterns and Debugging When Versions Drift
Once you're running immutable deploys at scale, a few edge cases will bite you if you're not ready. The most common is version drift during a rolling deploy. For about 15-45 seconds — the TTL window of your HTML — some edges will serve v2.4.1's HTML while others still serve v2.4.0's. That's expected and safe if every release is self-contained. It becomes a nightmare if your app does client-side lazy loading and fetches chunks without a hash. You'll get a v2.4.1 app shell trying to lazy-load checkout.js from v2.4.0. Fix this by ensuring every dynamic import is also content-hashed and referenced through the manifest, not a hardcoded URL.
Things get more interesting when your assets aren't all on one origin. If you're federating a marketing site from Webflow, app bundles from S3, and user uploads from another bucket, you need a single, consistent versioning contract across them. That's where CDN Content Federation: Aggregating Content from Multiple Origins into Unified Delivery becomes critical — your CDN has to treat the federated path as one atomic release, not three independent caches. Without that, a rollback might revert your app code but not your federated header fragment, leaving users with a mismatched UI. Similarly, understanding CDN Content Replication and Consistency Models: Eventual, Strong, and Causal helps you decide if you can tolerate eventual consistency (most static sites can, for 30-60 seconds) or if you need strong consistency for your pointer file.
When something still looks stale, don't just hit "Purge All." That's a sledgehammer that kills your hit ratio for hours. Instead, debug methodically. First, check the Age and X-Cache headers — is it a HIT or MISS, and how old is it? Second, request the HTML directly from origin bypassing the CDN to confirm the origin actually updated. Third, check for Cache-Control misconfiguration — I've seen teams set immutable on index.html by accident and then wonder why rollbacks take an hour. For global deployments, Multi-Region Content Synchronization: Keeping CDN Edges in Agreement is worth auditing; if your origin replication lag is 90 seconds but your HTML TTL is 15 seconds, edges near the secondary region will flap between versions. Align your TTLs to be slightly longer than your worst-case origin sync time, or use an origin shield to give all edges a single source of truth.
Frequently Asked Questions
How long should I cache my index.html if I want instant rollbacks?
Keep it short, but not zero. For most teams, 30-60 seconds on the edge and 15 seconds on the CDN-cache layer is ideal. You'll get a 95%+ hit ratio on that file while keeping rollback propagation under a minute. If you deploy more than 10 times per hour or need sub-10-second rollbacks, use `max-age=0, must-revalidate` with `stale-while-revalidate=10` — you'll take a small hit on origin load, but rollbacks become nearly instantaneous.
Do I ever need to purge the CDN with immutable assets?
Almost never. That's the whole point. Since each filename is unique to its content (`app.9b3f2a1c.js` will never change), you never overwrite or purge it. The only file you might purge is `index.html` if you need a rollback faster than its TTL allows. In practice, we tell teams to just re-deploy the previous `index.html` and wait 60 seconds rather than issuing a global purge, which drops your edge hit ratio from 92% to 0% instantly.
What about service workers and client-side caching?
Service workers are a separate cache layer you must version too. Don't cache your service worker script with an immutable header — browsers check for updates only when the byte content changes and the file isn't overly cached. Serve `/sw.js` with `max-age=0` and ensure it precaches only the hashed assets for its specific version. Otherwise, users can get stuck on a service worker from three releases ago that keeps serving stale chunks even after your CDN has rolled forward.
My hashed assets are filling up my origin storage. Can I delete old versions?
Yes, but with a delay. Keep at least the last 3-5 releases live on origin for at least 7-14 days. Remember, edges and browsers may still have the old `index.html` cached, and users with long-lived tabs might still request chunks from last week's deploy. An S3 lifecycle rule that deletes objects in `/releases/*` older than 14 days is a safe default. Never delete the assets for the currently-live release and the immediately-previous one.