Introduction
Static site generation has evolved far beyond the "build once, deploy everywhere" philosophy of early JAMstack implementations. While CDNs democratized global content delivery, the conversation has largely stagnated around basic cache-control headers and TTL values. Today's high-performance static sites demand more sophisticated approaches—patterns that balance instant content updates with aggressive caching, handle deployment invalidation gracefully, and leverage multiple cache tiers to minimize origin load.
The gap between basic CDN configuration and production-grade caching architecture is where most SSG projects encounter performance ceilings. A site might load quickly on first visit but struggle with stale content after deployments. Assets might cache aggressively but cause cache-busting headaches. Or worse, conservative cache settings might render the CDN nearly useless, sending every request back to origin servers. These aren't edge cases; they're fundamental challenges that emerge as static sites scale beyond toy projects.
This article explores three interconnected caching patterns that professional SSG developers should master: immutable asset strategies using content hashing, stale-while-revalidate headers for dynamic freshness, and tiered cache architectures that distribute load intelligently. Each pattern addresses specific failure modes in traditional caching approaches while maintaining the performance benefits that make SSGs attractive. We'll examine not just how these patterns work, but when to apply them and what trade-offs they impose on your architecture.
The Traditional CDN Approach and Its Limitations
Most SSG tutorials end with "deploy to a CDN with aggressive caching" — typically setting Cache-Control: max-age=31536000 on everything and calling it done. This approach works until the first deployment when you realize users are seeing stale content, or until you discover that HTML pages cached for days are serving outdated product prices. The traditional fix involves either drastically reducing TTLs (undermining cache effectiveness) or implementing manual cache purges, which introduce deployment complexity and race conditions between purge operations and user requests.
The fundamental tension in traditional CDN caching stems from the HTTP cache model's assumption that URLs are stable identifiers for content. When you update index.html and deploy, the URL remains identical, but the content changes. CDNs have no inherent mechanism to detect this change—they dutifully serve the cached version until TTL expires. This forces a painful choice: either accept significant staleness windows or sacrifice cache efficiency. Modern caching patterns resolve this tension not through better TTL tuning, but through architectural changes that make content changes addressable at the URL level and provide mechanisms for graceful staleness handling.
Immutable Assets and Content-Addressed Caching
Content hashing transforms the caching model from time-based to content-based. Instead of serving app.js that changes with each deployment, you serve app.a3f2b8c1.js where the hash uniquely identifies that exact file content. Once deployed, that URL will never serve different content—it's immutable. This immutability enables the most aggressive caching possible: Cache-Control: public, max-age=31536000, immutable. The immutable directive, defined in RFC 8246, signals to browsers that even revalidation is unnecessary—this resource will never change.
The power of this pattern emerges in the deployment flow. When you rebuild your site with updated JavaScript, the build process generates new hashes: app.d9e4f7a2.js. Your HTML entry points update to reference these new hashes. When you deploy, the new assets upload alongside old ones (which remain cached at CDNs globally). Only the HTML changes, and because HTML typically has short or no cache TTL, users quickly receive the updated HTML pointing to new asset hashes. The old assets gradually age out of caches as users stop requesting them—no manual purge required, no cache invalidation complexity.
Modern SSG frameworks like Next.js, Gatsby, and Astro implement this pattern automatically during build. Webpack and Vite use [contenthash] substitutions in output filename templates, ensuring deterministic hashing based on file contents. The configuration looks deceptively simple:
// next.config.js
module.exports = {
webpack: (config) => {
config.output.filename = 'static/chunks/[name].[contenthash].js';
return config;
},
};
However, effective content hashing requires discipline around asset references. Any hardcoded asset URLs in your code bypass the hash mechanism. Instead, all asset imports should flow through your build tool's module system or a manifest file that maps logical names to hashed filenames. For example, importing images in Next.js:
import Image from 'next/image';
import heroImage from '../public/hero.jpg'; // Webpack processes this import
export default function Hero() {
return (
<Image
src={heroImage} // src receives hashed URL automatically
alt="Hero image"
priority
/>
);
}
The build process generates an asset manifest mapping these imports to their hashed URLs, which gets embedded in your JavaScript bundles. At runtime, when components reference heroImage, they receive the hashed path. This ensures consistency between your code and CDN-cached assets even across deployments.
Stale-While-Revalidate: Performance Without Sacrifice
Content hashing solves immutable assets elegantly but breaks down for dynamic or semi-dynamic content — HTML pages, API responses, rendered Markdown. These resources require fresh data but benefit tremendously from caching. The stale-while-revalidate (SWR) directive, also from RFC 5861, provides a middle ground: serve cached content instantly while asynchronously fetching fresh content for the next request.
The mechanism is subtle but powerful. Consider Cache-Control: max-age=60, stale-while-revalidate=86400. For the first 60 seconds after caching, the response is fresh—caches serve it without revalidation. After 60 seconds, the response becomes stale but remains eligible for SWR. When a request arrives during this stale period, the cache immediately returns the stale response (ensuring instant delivery) while simultaneously triggering a background revalidation request to origin. If revalidation succeeds, the cache updates for subsequent requests. If origin is down, the stale content continues serving, providing resilience.
This pattern excels for content that changes infrequently but requires reasonable freshness. Blog post HTML might use max-age=300, stale-while-revalidate=86400—five minutes of guaranteed freshness, but stale content can serve for 24 hours while revalidating. Users rarely see loading states, origin sees drastically reduced request volume, and content freshness degrades gracefully rather than cliff-edging at TTL expiration.
// Netlify _headers file configuration
/*
Cache-Control: public, max-age=0, must-revalidate
/blog/*
Cache-Control: public, max-age=300, stale-while-revalidate=86400
/api/*
Cache-Control: public, max-age=60, stale-while-revalidate=3600
/static/*
Cache-Control: public, max-age=31536000, immutable
The key insight is that SWR decouples response time from freshness requirements. Traditional caching forces you to choose: fast responses with long TTLs (accepting staleness) or fresh content with short TTLs (sacrificing cache hit rates). SWR delivers fast responses AND fresh content by making freshness asynchronous. The first user after staleness might receive slightly outdated content, but they receive it instantly, and their request ensures freshness for subsequent users.
Tiered Cache Architectures
Single-tier CDN caching treats all edge locations equally, but modern architectures recognize different cache tiers with distinct characteristics. A typical tiered design includes browser cache (fast, user-specific, small), CDN edge cache (fast, user-agnostic, geographically distributed), CDN origin shield or regional cache (moderate speed, higher hit rate, fewer locations), and origin cache (slowest, highest memory, single or few locations). Each tier trades latency for hit rate and cost efficiency.
The architecture's elegance emerges in how requests cascade through tiers. A request hits the nearest edge node first. On cache miss, rather than immediately contacting origin (potentially thousands of miles away), the edge requests from a regional shield cache. The shield has a much higher hit rate because it aggregates requests from many edge nodes. Only on shield miss does the request reach origin. This topology reduces origin load by orders of magnitude while maintaining low latency—most requests are served from edge (best case), some from shield (acceptable case), and only a tiny percentage from origin (worst case).
Implementing tiered caching requires CDN configuration support, which major providers like Cloudflare, Fastly, and AWS CloudFront offer through "origin shield" or "regional caching" features. The configuration typically specifies which origin requests should route through shield and which regions to use:
// Cloudflare Workers example implementing cache tier logic
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
// Try edge cache first
let response = await cache.match(cacheKey);
if (!response) {
// Miss at edge - try shield tier
response = await fetch(request, {
cf: {
cacheEverything: true,
cacheTtlByStatus: {
"200-299": 86400,
"404": 3600,
"500-599": 0
},
// Enable origin shield
shieldRegion: "us-west",
}
});
// Store in edge cache for next request
await cache.put(cacheKey, response.clone());
}
return response;
}
};
Practical Implementation Patterns
Combining these patterns requires coordinating build tooling, CDN configuration, and origin server headers. A production-ready implementation typically segments content into categories with distinct caching strategies. Static assets (JavaScript, CSS, images with content hashes) receive immutable caching. HTML pages use SWR with short max-age. API routes or data files use even shorter max-age with aggressive SWR. Each category maps to specific URL patterns in your CDN configuration.
Build-time decisions profoundly impact caching effectiveness. Consider font files—these rarely change but typically don't receive content hashes by default. A robust implementation includes fonts in the hashing pipeline, even if it requires custom build configuration:
// Vite config for comprehensive asset hashing
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
// Hash all static assets including fonts
const info = assetInfo.name.split('.');
const ext = info[info.length - 1];
if (/png|jpe?g|svg|gif|tiff|bmp|ico|webp/i.test(ext)) {
return `assets/images/[name].[hash][extname]`;
}
if (/woff2?|ttf|eot/i.test(ext)) {
return `assets/fonts/[name].[hash][extname]`;
}
return `assets/[name].[hash][extname]`;
},
chunkFileNames: 'assets/js/[name].[hash].js',
entryFileNames: 'assets/js/[name].[hash].js',
},
},
},
});
Header configuration should exist in version control alongside code, not buried in CDN admin panels. Infrastructure-as-code approaches ensure consistency between environments and enable atomic deployments where configuration changes deploy alongside code changes. For Vercel, this means vercel.json; for Netlify, netlify.toml or _headers files; for CloudFront, CDK or Terraform definitions:
// vercel.json
{
"headers": [
{
"source": "/static/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/(.*).html",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate, stale-while-revalidate=31536000"
}
]
}
]
}
Cache key design becomes critical in tiered architectures. By default, CDNs cache based on full URL including query parameters, which fragments cache unnecessarily. A URL like /product?id=123&referrer=email&session=abc should likely cache based only on id, not the tracking parameters. Custom cache key configuration normalizes these variations:
# Cloudflare Workers cache key normalization
def normalize_cache_key(url: str) -> str:
"""Extract only semantically meaningful query parameters for caching"""
from urllib.parse import urlparse, parse_qs, urlencode
parsed = urlparse(url)
params = parse_qs(parsed.query)
# Define which parameters affect content
cache_relevant = ['id', 'page', 'category']
filtered = {k: v for k, v in params.items() if k in cache_relevant}
# Rebuild URL with normalized parameters
normalized_query = urlencode(sorted(filtered.items()))
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{normalized_query}"
Trade-offs and Pitfalls
Immutable assets with content hashing introduce build complexity and potential cache pollution. Each deployment generates new asset filenames that upload to CDN and remain cached indefinitely. High deployment frequency can bloat CDN storage with orphaned assets. Most SSG projects don't deploy frequently enough for this to matter, but large teams with multiple daily deployments should implement asset cleanup strategies—either lifecycle policies that purge assets unreferenced for 30+ days, or build processes that track deployed hashes and explicitly delete old ones.
The SWR pattern has subtle failure modes around cache stampedes. If a popular page becomes stale and receives thousands of simultaneous requests, traditional SWR implementations might trigger thousands of simultaneous revalidation requests to origin—overwhelming the server. Production implementations need request coalescing, where the first stale request triggers revalidation and subsequent requests wait for that result rather than spawning independent revalidation requests. Cloudflare and Fastly implement this natively, but custom caching layers require explicit logic:
// Request coalescing for SWR to prevent origin stampede
const revalidationPromises = new Map<string, Promise<Response>>();
async function fetchWithCoalescing(url: string): Promise<Response> {
const existing = revalidationPromises.get(url);
if (existing) {
return existing; // Revalidation already in flight
}
const promise = fetch(url);
revalidationPromises.set(url, promise);
try {
const response = await promise;
return response;
} finally {
// Clean up after revalidation completes
revalidationPromises.delete(url);
}
}
Tiered caching can obscure debugging when cache behavior differs between tiers. A user might report stale content that you can't reproduce because your requests hit edge cache while theirs hit shield or origin. Effective debugging requires cache tier visibility—response headers that indicate which tier served the request. Implementing custom headers like X-Cache-Status: HIT-EDGE or X-Cache-Status: HIT-SHIELD or X-Cache-Status: MISS enables triaging cache issues without guesswork. This information should be logged alongside request metrics for post-hoc analysis of cache effectiveness.
Best Practices and Decision Framework
Start with content classification: truly immutable assets (hashed files), infrequently changing content (blog posts, documentation), frequently changing content (landing pages, user dashboards), and real-time content (API responses, personalized data). Map each category to appropriate patterns—immutable for hashed assets, aggressive SWR for infrequent changes, conservative SWR for frequent changes, minimal caching for real-time. Don't apply the same strategy universally; caching effectiveness comes from matching pattern to content characteristics.
Monitor cache hit rates per content category and per cache tier. A well-configured SSG should achieve >95% edge cache hit rate for hashed assets, >80% edge hit rate for HTML pages (depending on traffic patterns), and >90% shield hit rate for edge misses. Hit rates significantly below these thresholds indicate configuration problems—overly conservative TTLs, cache key fragmentation, or missing immutable directives. Modern CDN dashboards provide this telemetry; export it to your observability platform for correlation with deployment events and performance metrics.
Conclusion
Advanced caching patterns for SSG sites represent the maturation of static site architecture from simple "build and deploy" to sophisticated performance engineering. Content-addressed immutability, stale-while-revalidate headers, and tiered cache designs aren't exotic optimizations—they're fundamental patterns that separate toy projects from production systems handling serious traffic. Each pattern addresses specific limitations in naive CDN configuration while maintaining the operational simplicity that makes SSGs attractive.
The implementation path forward is incremental. Start by auditing current cache hit rates and identifying content categories. Implement content hashing for static assets if not already present—this provides the largest immediate improvement with minimal complexity. Layer in SWR for HTML pages, starting conservatively with short staleness windows and expanding based on monitoring. Finally, enable CDN shield or regional caching features to reduce origin load. Each step compounds with previous improvements, building toward a caching architecture that serves content instantly while maintaining freshness and resilience. The patterns outlined here aren't the ceiling of caching sophistication, but they represent the baseline that modern SSG applications should target.
References
- RFC 7234 - Hypertext Transfer Protocol (HTTP/1.1): Caching: IETF standards documentation for HTTP caching behavior. https://tools.ietf.org/html/rfc7234
- RFC 8246 - HTTP Immutable Responses: Specification for the
immutableCache-Control directive. https://tools.ietf.org/html/rfc8246 - RFC 5861 - HTTP Cache-Control Extensions for Stale Content: Defines
stale-while-revalidateandstale-if-errordirectives. https://tools.ietf.org/html/rfc5861 - Cloudflare Cache Documentation: Implementation guide for tiered caching with origin shield and cache key customization. https://developers.cloudflare.com/cache/
- Webpack Caching Guide: Official documentation on content hashing and long-term caching strategies. https://webpack.js.org/guides/caching/
- Vite Build Optimizations: Documentation covering asset handling and output filename patterns. https://vitejs.dev/guide/build.html
- Next.js Production Best Practices: Framework-specific guidance on caching, CDN configuration, and deployment patterns. https://nextjs.org/docs/going-to-production
- Fastly's Guide to Cache Control: Practical implementation patterns for sophisticated cache configuration. https://developer.fastly.com/learning/concepts/cache-control/
- High Performance Browser Networking by Ilya Grigorik (O'Reilly, 2013): Comprehensive coverage of HTTP caching mechanisms and CDN architecture patterns.