I run my site (the same one I wrote about in my brick and mortar business stack posts) behind a CDN. Every page request goes through it before hitting my backend (TanStack Start on Lambda). On a content site, most of those requests return the same HTML. It felt wasteful to have the Lambda wake up for every single one.
The obvious fix is to set a long cache header. Cache-Control: public, max-age=86400, s-maxage=86400. The CDN serves the cached version for a day, the Lambda stays cold, everyone wins.
But there is a catch. When the cache expires at hour 25, the next visitor triggers a fetch. They wait. On a Lambda with a cold start, that wait is noticeable. Not terrible, but enough that I did not want to ship it.
This is where stale-while-revalidate comes in.
The header looks like this:
Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400
What it does:
- The CDN caches the response for 24 hours (
s-maxage=86400). - The browser caches it for 1 hour (
max-age=3600). - For another 24 hours after expiry, the CDN can serve the stale version instantly while it fetches a fresh copy in the background (
stale-while-revalidate=86400).
So the timeline for a reader visiting my site looks like:
- First visit: CDN misses, Lambda serves the page, CDN caches it.
- Next 24 hours: every visitor gets the cached version. Zero Lambda invocations. Instant response.
- Between hour 24 and 48: the CDN cache has expired, but the reader still gets the stale page instantly from the edge. One background request goes to Lambda to refresh the cache. The reader never waits.
- After hour 48: just repeat step 3.
The result: one Lambda invocation per day per page, instead of one per visitor. My readers get fast pages regardless. The only trade-off is up to 24 hours of staleness, which is fine for a blog post. If I need to publish something urgent, I can purge the CDN cache manually.
I have a small set of cache profiles I reuse across projects. The stale-while-revalidate values change depending on the content type. For article pages I keep it wide. For listing pages, tighter. But the pattern is always the same: cache long, revalidate in the background, never let the reader wait.
Here is the full config I keep in my codebase, if you want something to start from:
// ============================================================================
// NO CACHE - Prevents all caching
// Use for: Sensitive data, user-specific content, real-time data
// Example: User profile, cart, authentication state
// ============================================================================
export const noCache = 'no-store, no-cache, must-revalidate, proxy-revalidate'
// ============================================================================
// SHORT CACHE - 1 minute browser, 5 minutes CDN
// Use for: Frequently changing data that needs freshness
// Example: Stock prices, live scores, active user counts
// ============================================================================
export const shortCache =
'public, max-age=60, s-maxage=300, stale-while-revalidate=60'
// ============================================================================
// MEDIUM CACHE - 5 minutes browser, 1 hour CDN
// Use for: Semi-static content that updates periodically
// Example: Blog listing, product catalog, news feed
// ============================================================================
export const mediumCache =
'public, max-age=300, s-maxage=3600, stale-while-revalidate=300'
// ============================================================================
// ONE HOUR CACHE - No browser cache, 1 hour CDN with revalidation
// Use for: Content that should always be fresh but CDN can cache
// Example: API responses, dynamic pages, user dashboard data
// ============================================================================
export const oneHourCache =
'public, max-age=0, s-maxage=3600, stale-while-revalidate=3600'
// ============================================================================
// LONG CACHE - 1 hour browser, 1 day CDN
// Use for: Rarely changing content
// Example: Product details, article content, category pages
// ============================================================================
export const longCache =
'public, max-age=3600, s-maxage=86400, stale-while-revalidate=3600'
// ============================================================================
// STATIC ASSETS - 1 year cache with immutable flag
// Use for: Versioned static files (JS, CSS, images with hash in filename)
// ============================================================================
export const staticAssetCache = 'public, max-age=31536000, immutable'
// ============================================================================
// IMAGE CACHE - 7 days browser, 30 days CDN
// Use for: Images and media files that rarely change
// ============================================================================
export const imageCache =
'public, max-age=604800, s-maxage=2592000, stale-while-revalidate=86400'
// ============================================================================
// API RESPONSE CACHE - No browser cache, CDN-only caching
// Use for: Public API endpoints, RSS feeds, JSON data
// ============================================================================
export const apiCache =
'public, max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=300'
// ============================================================================
// PRIVATE CACHE - Browser-only caching, no CDN/proxy
// Use for: User-specific data that should not be shared across users
// ============================================================================
export const privateCache = 'private, max-age=300, stale-while-revalidate=60'
// ============================================================================
// HELPER: Create custom cache header with any combination
// ============================================================================
export function createCacheHeader(options: {
public?: boolean
maxAge?: number
sMaxAge?: number
staleWhileRevalidate?: number
staleIfError?: number
immutable?: boolean
} = {}): string {
const {
public: isPublic = true,
maxAge = 0,
sMaxAge = maxAge,
staleWhileRevalidate = 0,
staleIfError = 0,
immutable = false,
} = options
return [
isPublic ? 'public' : 'private',
`max-age=${maxAge}`,
isPublic && sMaxAge !== maxAge ? `s-maxage=${sMaxAge}` : null,
staleWhileRevalidate > 0 ? `stale-while-revalidate=${staleWhileRevalidate}` : null,
staleIfError > 0 ? `stale-if-error=${staleIfError}` : null,
immutable ? 'immutable' : null,
].filter((d): d is string => d !== null).join(', ')
}
If you are running a content site on Lambda or any serverless backend, this is the easiest performance and cost win. Pick the right profile, set it as your response header, done.