Loading content...
What if you could eliminate cache invalidation entirely? No purge API calls. No propagation delays. No rate limits. No verification polling. Just instant cache freshness by design.
Versioned URLs achieve exactly this by embedding content identifiers directly into URLs. Instead of purging /app.js when code changes, you deploy /app.v2.abc123.js—a completely new URL that bypasses all cached copies of the old version. The old version remains cached harmlessly forever (or until eventual eviction), while users immediately receive the new version.
This pattern is so powerful that it's the default strategy for virtually all modern frontend build tools: Webpack, Vite, Parcel, Next.js, and others all generate content-hashed filenames. Understanding why—and how to apply this pattern beyond assets—is fundamental to CDN-scale content delivery.
By the end of this page, you will understand versioned URL strategies comprehensively: cache-busting techniques, build tool integration, long-lived cache headers, performance implications, and how to extend versioning beyond static assets to APIs and dynamic content.
Versioned URLs are based on a simple but profound insight: if content at a URL never changes, you never need to invalidate it. By treating URLs as immutable and changing the URL when content changes, cache invalidation becomes a non-problem.
The Traditional Problem:
URL: /static/app.js
Content: (version 1 of your application code)
Cache-Control: max-age=3600 (1 hour)
Problem: You deploy version 2.
Users may see version 1 for up to 1 hour.
You need to purge all edge caches immediately.
The Versioned URL Solution:
URL: /static/app.abc123.js (hash: abc123)
Content: (version 1 of your application code)
Cache-Control: max-age=31536000, immutable (1 year)
Deploy version 2:
New URL: /static/app.def456.js (hash: def456)
Content: (version 2 of your application code)
No purge needed!
HTML references new URL, users get new version instantly.
Old version ignored (different URL).
1234567891011121314151617181920212223
TRADITIONAL CACHING: Same URL, Changing Content═══════════════════════════════════════════════════════════════════ Time | URL | Content | CDN Cache | User Gets───────┼───────────────┼──────────┼───────────┼───────────T0 | /app.js | v1 | v1 | v1 ✓T1 | (deploy v2) | v2 | v1 (stale)| v1 ✗T2 | (purge) | v2 | MISS | v2 (slow)T3 | /app.js | v2 | v2 | v2 ✓ Problems: Stale window (T1-T2), slow refresh (T2), purge complexity VERSIONED URLS: Different URL per Content Version═══════════════════════════════════════════════════════════════════ Time | URL | Content | CDN Cache | User Gets───────┼──────────────────┼─────────┼──────────────┼───────────T0 | /app.abc.js | v1 | v1 | v1 ✓T1 | /app.def.js | v2 | v2 (new URL) | v2 ✓ instant!T2 | /app.def.js | v2 | v2 (HIT) | v2 ✓ Benefits: No stale window, instant freshness, no purge neededimmutable directive.Versioned URLs work perfectly for assets referenced from HTML. But how does the HTML know which version to reference? HTML itself cannot be versioned this way (its URL is typically fixed, e.g., /index.html). This necessitates a two-tier caching strategy—short TTL for HTML, very long TTL for versioned assets. We'll explore this in detail.
There are multiple approaches to generating version identifiers for URLs. Each has distinct characteristics suitable for different scenarios.
| Strategy | Format Example | Determinism | Cache Efficiency | Best For |
|---|---|---|---|---|
| Content Hash | app.a3f2b1c.js | 100% deterministic | Optimal (same content = same hash) | Build artifacts, static assets |
| Build ID | app.build-1234.js | Build-deterministic | Good (per-build) | Deployment artifacts |
| Timestamp | app.1704067200.js | Time-based | Poor (changes every build) | Simple build systems |
| Semantic Version | app.v2.3.1.js | Manual | Moderate | Versioned APIs, libraries |
| Git Commit | app.abc1234.js | Deterministic (per commit) | Good | Development/staging |
Content Hashing: The Gold Standard
Content hashing generates a cryptographic hash of the file content, typically truncated to 8-16 characters. This provides perfect cache efficiency: if two builds produce identical file content, they generate identical hashes, and the CDN serves the already-cached version.
File content: 'console.log("hello");'
SHA-256 hash: a3f2b1c8d4e5f6...
Filename: app.a3f2b1c.js
Same content in different build → Same hash → Cache HIT
Different content → Different hash → Cache MISS (fresh fetch)
This is why all modern JavaScript bundlers default to content hashing—it minimizes cache invalidation to only truly changed files.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
import * as crypto from 'crypto';import * as fs from 'fs';import * as path from 'path'; interface HashedAsset { originalPath: string; hashedPath: string; hash: string; content: Buffer;} /** * Generates content-hashed filename for cache-busting. * Uses first 8 characters of SHA-256 hash. */function hashFileContent(content: Buffer): string { return crypto .createHash('sha256') .update(content) .digest('hex') .substring(0, 8);} function generateHashedFilename( originalFilename: string, content: Buffer): string { const hash = hashFileContent(content); const ext = path.extname(originalFilename); const basename = path.basename(originalFilename, ext); // Insert hash before extension: app.js → app.a3f2b1c8.js return `${basename}.${hash}${ext}`;} // Build pipeline exampleasync function buildWithHashing( inputDir: string, outputDir: string): Promise<Map<string, string>> { const manifest = new Map<string, string>(); const files = await fs.promises.readdir(inputDir); for (const file of files) { const inputPath = path.join(inputDir, file); const content = await fs.promises.readFile(inputPath); const hashedFilename = generateHashedFilename(file, content); const outputPath = path.join(outputDir, hashedFilename); await fs.promises.writeFile(outputPath, content); // Map original → hashed for HTML template substitution manifest.set(file, hashedFilename); console.log(`${file} → ${hashedFilename}`); } // Write manifest for runtime reference await fs.promises.writeFile( path.join(outputDir, 'manifest.json'), JSON.stringify(Object.fromEntries(manifest), null, 2) ); return manifest;} // Example output:// app.js → app.a3f2b1c8.js// styles.css → styles.7e4d9f2a.css// logo.png → logo.b8c3d1e5.pngContent hashing only provides cache benefits if your builds are deterministic—identical source should produce identical output. Watch for non-determinism sources: timestamps in comments, random identifiers, unstable import ordering. Use reproducible build practices.
Modern build tools provide built-in content hashing support. Understanding configuration options enables optimal cache behavior.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// webpack.config.js const path = require('path');const MiniCssExtractPlugin = require('mini-css-extract-plugin');const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); module.exports = { mode: 'production', output: { path: path.resolve(__dirname, 'dist'), // Content hash in JavaScript filenames // [contenthash:8] = 8-character hash based on file content filename: 'js/[name].[contenthash:8].js', // Hash for code-split chunks chunkFilename: 'js/[name].[contenthash:8].chunk.js', // Hash for assets (images, fonts) assetModuleFilename: 'assets/[name].[contenthash:8][ext]', // Clean output directory on each build clean: true, }, plugins: [ // CSS with content hash new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css', chunkFilename: 'css/[name].[contenthash:8].chunk.css', }), // Generate manifest.json mapping original → hashed names new WebpackManifestPlugin({ fileName: 'asset-manifest.json', publicPath: '/', generate: (seed, files, entrypoints) => { const manifest = {}; files.forEach(file => { manifest[file.name] = file.path; }); return manifest; }, }), ], optimization: { // Ensure consistent module IDs across builds moduleIds: 'deterministic', // Separate runtime chunk for better caching runtimeChunk: 'single', // Split vendor code into separate chunk splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', }, }, }, },}; // Output structure:// dist/// js/// main.a3f2b1c8.js// vendors.7d4e9f2a.js// runtime.b8c3d1e5.js// css/// main.c2d4e6f8.css// assets/// logo.f1e2d3c4.png// asset-manifest.json12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// vite.config.ts import { defineConfig } from 'vite';import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], build: { // Enable content hashing (default in production) rollupOptions: { output: { // JavaScript entry points entryFileNames: 'assets/[name]-[hash].js', // Code-split chunks chunkFileNames: 'assets/[name]-[hash].js', // Static assets (images, fonts, etc.) assetFileNames: (assetInfo) => { // Organize by file type const info = assetInfo.name.split('.'); const ext = info[info.length - 1]; if (/png|jpg|jpeg|gif|svg|webp|avif/.test(ext)) { return 'images/[name]-[hash][extname]'; } if (/woff|woff2|eot|ttf|otf/.test(ext)) { return 'fonts/[name]-[hash][extname]'; } return 'assets/[name]-[hash][extname]'; }, // Manual chunk splitting for optimal caching manualChunks: (id) => { if (id.includes('node_modules')) { // Split large libraries into separate chunks if (id.includes('react')) return 'react-vendor'; if (id.includes('lodash')) return 'lodash-vendor'; return 'vendor'; } }, }, }, // Generate manifest for server-side reference manifest: true, // Source maps for debugging (separate files, cached separately) sourcemap: true, },}); // Vite generates:// dist/// assets/// index-Ds2BjR4k.js// index-BkH5Q8Xa.css// react-vendor-At2Hc5Kl.js// vendor-Mv8Jn3Lo.js// images/// hero-Bc3De5Fg.webp// .vite/// manifest.jsonSeparating vendor code (node_modules) from application code enables independent caching. When you update your app code, the vendor chunk remains unchanged with the same hash—users don't re-download libraries. This can reduce deployment bandwidth by 80%+.
Versioned URLs enable extremely aggressive caching because the content at any given URL is guaranteed immutable. The optimal Cache-Control configuration maximizes cache efficiency while maintaining browser compatibility.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
VERSIONED ASSETS (hashed filenames)═══════════════════════════════════════════════════════════════════ Cache-Control: public, max-age=31536000, immutable Components: • public — Cacheable by CDN and browser • max-age — 31536000 seconds = 1 year • immutable — Never revalidate (content will never change at this URL) Why 1 year? • Maximum practical cache lifetime • Browser may evict earlier due to space constraints • Content guaranteed fresh because URL contains hash Why immutable? • Prevents browsers from revalidating on reload • Without it, browsers may send If-None-Match requests • "immutable" indicates revalidation is unnecessary • Supported in modern browsers (Firefox, Chrome, Safari) UNVERSIONED ASSETS / HTML (fixed URLs)═══════════════════════════════════════════════════════════════════ Cache-Control: public, max-age=0, must-revalidate Or for balance between freshness and CDN efficiency: Cache-Control: public, max-age=60, stale-while-revalidate=300 Components: • max-age=0 — Always check with origin • must-revalidate — Don't serve stale content • stale-while-revalidate — Serve stale while fetching fresh (CDN) CONFIGURATION MATRIX═══════════════════════════════════════════════════════════════════ Content Type | Versioned? | Cache-Control───────────────────┼────────────┼───────────────────────────────────*.js (hashed) | Yes | public, max-age=31536000, immutable*.css (hashed) | Yes | public, max-age=31536000, immutableImages (hashed) | Yes | public, max-age=31536000, immutableFonts (hashed) | Yes | public, max-age=31536000, immutableindex.html | No | public, max-age=0, must-revalidateAPI responses | Varies | Depends on endpoint (see section 6)12345678910111213141516171819202122232425262728293031323334353637383940
# /etc/nginx/conf.d/cache-headers.conf # Match hashed asset patterns# Patterns: *.abc123.js, *.abc123.css, *-abc123.pnglocation ~* \.(js|css|png|jpg|jpeg|gif|webp|avif|svg|woff2?|ttf|eot)$ { # Check if filename contains hash pattern if ($uri ~* ".*[\.-][a-f0-9]{8,}\.[^.]+$") { # Highly aggressive caching for hashed assets add_header Cache-Control "public, max-age=31536000, immutable"; add_header X-Cache-Strategy "versioned-immutable"; } # Fallback for non-hashed static assets if ($uri !~* ".*[\.-][a-f0-9]{8,}\.[^.]+$") { add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800"; add_header X-Cache-Strategy "static-revalidate"; } # Enable gzip/brotli for text assets gzip_static on; brotli_static on;} # HTML files - must always revalidatelocation ~* \.html$ { add_header Cache-Control "public, max-age=0, must-revalidate"; add_header X-Cache-Strategy "html-revalidate";} # Service Worker - never cache (controls its own caching)location = /sw.js { add_header Cache-Control "no-store"; add_header X-Cache-Strategy "service-worker-no-store";} # Manifest files - short cache with revalidationlocation ~* \.(json|webmanifest)$ { add_header Cache-Control "public, max-age=60, stale-while-revalidate=300"; add_header X-Cache-Strategy "manifest-short-cache";}"immutable" tells browsers not to revalidate even on hard refresh. If you accidentally serve wrong content with an immutable header, users are stuck until cache eviction. Ensure content is truly immutable (content-hashed) before applying. Never use immutable with non-hashed filenames.
Versioned URLs work perfectly for assets referenced from HTML. But HTML itself presents a chicken-and-egg problem: the HTML URL (e.g., /index.html or /) must remain stable for users to navigate to your site, yet it needs to reference the latest versioned assets.
The Problem:
main.abc123.js)Cache-Control: no-cache or max-age=0 on HTML. Every request revalidates with origin. Simple but increases origin load.max-age=60 or similar. Balance between freshness and origin protection. Acceptable staleness window.max-age=60, stale-while-revalidate=300. Serve stale HTML while revalidating in background. Best user experience.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// Deployment pipeline with HTML purge interface DeploymentConfig { cdnClient: CDNClient; buildArtifacts: BuildArtifacts; htmlPages: string[];} async function deploy(config: DeploymentConfig): Promise<void> { const { cdnClient, buildArtifacts, htmlPages } = config; // Step 1: Upload versioned assets (aggressive caching) // These are immutable - no purge needed ever for (const asset of buildArtifacts.hashedAssets) { await uploadToOrigin(asset.path, asset.content, { cacheControl: 'public, max-age=31536000, immutable', contentType: asset.mimeType }); } console.log(`Uploaded ${buildArtifacts.hashedAssets.length} versioned assets`); // Step 2: Upload HTML with new asset references for (const page of buildArtifacts.htmlPages) { // HTML references hashed asset URLs from manifest const htmlWithRefs = injectAssetReferences( page.content, buildArtifacts.manifest ); await uploadToOrigin(page.path, htmlWithRefs, { cacheControl: 'public, max-age=60, stale-while-revalidate=300', contentType: 'text/html' }); } console.log(`Uploaded ${buildArtifacts.htmlPages.length} HTML pages`); // Step 3: Purge ONLY HTML pages from CDN // Versioned assets don't need purging (new URLs) await cdnClient.purge({ urls: htmlPages.map(p => `https://example.com${p}`), type: 'soft' // Soft purge for availability }); console.log(`Purged ${htmlPages.length} HTML pages`); // Step 4: Verify HTML freshness await verifyHtmlFreshness(htmlPages); console.log('Deployment complete!');} function injectAssetReferences( html: string, manifest: Record<string, string>): string { // Replace asset placeholders with hashed URLs // <script src="__ASSET_main.js__"></script> // becomes // <script src="/assets/main.a3f2b1c8.js"></script> let result = html; for (const [original, hashed] of Object.entries(manifest)) { const placeholder = `__ASSET_${original}__`; result = result.replace(new RegExp(placeholder, 'g'), `/${hashed}`); } return result;} // HTML template example:// <!DOCTYPE html>// <html>// <head>// <link rel="stylesheet" href="__ASSET_styles/main.css__">// </head>// <body>// <div id="root"></div>// <script src="__ASSET_js/main.js__"></script>// </body>// </html>Only purge HTML pages that actually reference changed assets. If only the /products page bundle changed, you may only need to purge /products/*—not your entire site's HTML. Track which pages reference which asset bundles for surgical purging.
While versioned URLs are most commonly applied to static assets, the pattern can extend to API responses and dynamic content—enabling aggressive CDN caching for traditionally "uncacheable" data.
Versioned API Endpoints:
Consider an API returning product data that changes infrequently:
Traditional: GET /api/products/123
Response: { name: "Widget", price: 99.99, version: 42 }
Cache-Control: max-age=60 (must refresh frequently)
Versioned: GET /api/products/123?v=42
Response: { name: "Widget", price: 99.99 }
Cache-Control: public, max-age=31536000, immutable
When product changes: Client requests ?v=43 (new content)
The client obtains the current version number from a lightweight, short-TTL endpoint, then requests the versioned data endpoint which can be cached indefinitely.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
/** * Versioned API Pattern * * Split API into version discovery (short cache) and * data retrieval (long cache with version in URL). */ // Version discovery endpoint - short cache, always current// GET /api/products/123/versionapp.get('/api/products/:id/version', async (req, res) => { const product = await db.getProduct(req.params.id); res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60'); res.json({ id: req.params.id, version: product.version, // Include version of referenced entities categoryVersion: product.category.version, brandVersion: product.brand.version, // URL for versioned data dataUrl: `/api/products/${req.params.id}/data?v=${product.version}` });}); // Versioned data endpoint - aggressive caching// GET /api/products/123/data?v=42app.get('/api/products/:id/data', async (req, res) => { const version = parseInt(req.query.v as string); if (!version) { return res.status(400).json({ error: 'Version parameter required' }); } const product = await db.getProductAtVersion(req.params.id, version); if (!product) { return res.status(404).json({ error: 'Version not found' }); } // Immutable caching - version is in URL res.set('Cache-Control', 'public, max-age=31536000, immutable'); res.json(product);}); // Client usage patternclass ProductClient { private versionCache = new Map<string, number>(); async getProduct(id: string): Promise<Product> { // Step 1: Get current version (short cache hit or fresh) const versionInfo = await fetch(`/api/products/${id}/version`) .then(r => r.json()); const cachedVersion = this.versionCache.get(id); if (cachedVersion === versionInfo.version) { // Version unchanged - use browser cache console.log(`Product ${id} version unchanged, using cache`); } else { // Version changed - update local cache reference this.versionCache.set(id, versionInfo.version); } // Step 2: Fetch versioned data (long cache hit likely) const product = await fetch(versionInfo.dataUrl) .then(r => r.json()); return product; }} /** * ETag-Based Alternative * * For simpler implementation, use strong ETags with version: */app.get('/api/products/:id', async (req, res) => { const product = await db.getProduct(req.params.id); const etag = `"${product.version}"}`; // Strong ETag // Check If-None-Match if (req.headers['if-none-match'] === etag) { return res.status(304).end(); // Not Modified } res.set('ETag', etag); res.set('Cache-Control', 'public, max-age=60'); res.json(product);});Versioned API patterns work best when: (1) Data changes infrequently relative to read frequency, (2) Clients can tolerate split-second staleness during version propagation, (3) The version discovery overhead is worthwhile for caching gains. For frequently changing data or strict consistency requirements, traditional short TTLs remain appropriate.
Migrating an existing application to versioned URLs requires careful planning to avoid breaking cached references and ensure smooth transition.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Express middleware for graceful migration import { Router } from 'express';import manifest from './asset-manifest.json'; const migrationRouter = Router(); // Build reverse lookup: original filename → hashed URLconst reverseManifest = new Map<string, string>();for (const [original, hashed] of Object.entries(manifest)) { const originalFilename = original.split('/').pop()!; reverseManifest.set(originalFilename, '/' + hashed);} /** * Legacy URL handler * Redirects /static/app.js → /static/app.abc123.js * Enables gradual migration without breaking existing references */migrationRouter.get('/static/:filename', (req, res, next) => { const { filename } = req.params; // Check if this is a legacy (non-hashed) request const isHashed = /[.-][a-f0-9]{8,}.[^.]+$/.test(filename); if (!isHashed) { const hashedUrl = reverseManifest.get(filename); if (hashedUrl) { // Redirect to hashed URL // 302 for migration period, switch to 301 after stable // Track legacy requests for monitoring metrics.increment('asset.legacy_redirect', { filename }); return res.redirect(302, hashedUrl); } } // Hashed URL or not in manifest - proceed normally next();}); /** * Migration monitoring * Track legacy URL usage to know when migration is complete */function analyzeLegacyUsage(): MigrationReport { const legacyRequests = metrics.query('asset.legacy_redirect', { period: 'last_7_days' }); const byFile = new Map<string, number>(); for (const request of legacyRequests) { const count = byFile.get(request.filename) || 0; byFile.set(request.filename, count + 1); } return { totalLegacyRequests: legacyRequests.length, requestsByFile: Object.fromEntries(byFile), recommendation: legacyRequests.length < 100 ? 'Low legacy usage - safe to remove redirects' : 'Significant legacy usage - continue monitoring' };} // Periodic check for migration completionsetInterval(async () => { const report = analyzeLegacyUsage(); console.log('Migration status:', report); if (report.totalLegacyRequests === 0) { alerting.info('Asset migration complete - zero legacy requests detected'); }}, 24 * 60 * 60 * 1000); // DailyThird parties may hotlink your assets or have cached HTML referencing old URLs. Before removing legacy URL support, ensure sufficient time has passed for external caches to expire and notify known integrators of the URL structure change.
Versioned URLs represent an elegant solution to cache invalidation by side-stepping the problem entirely. When content at a URL never changes, invalidation becomes irrelevant.
max-age=31536000, immutable for versioned assets.What's Next:
Versioned URLs and purge requests are complementary strategies. The final page explores Invalidation Latency—understanding and optimizing the time between content change and global cache freshness, regardless of which invalidation strategy you employ.
You now understand versioned URL strategies comprehensively—from core concepts to build tool integration, cache-control optimization, HTML handling, API versioning, and migration strategies.