Page speed, redirects, Core Web Vitals
Set Cache-Control headers to enable browser and CDN caching
Without Cache-Control headers every visit re-downloads every asset. Configure long-lived caching for hashed assets and stale-while-revalidate for HTML.
What's happening
Cache-Control is the HTTP header that tells browsers and CDNs how long to keep a response. Without it, browsers default to heuristic caching (typically 10% of Last-Modified age) and CDNs typically don't cache at all. Every repeat visit re-downloads every asset, paying full network cost.
Lighthouse's "Serve static assets with an efficient cache policy" audit flags assets without an explicit max-age (or with very short max-age values). DevTools' Network panel shows the Cache-Control header per response and an effective cache lifetime in the Headers tab.
The trickiest part is balancing freshness with cache efficiency. HTML needs to be revalidated quickly so new content appears, but static assets (JS, CSS, fonts, images with hashed filenames) can be cached forever because the URL changes when the content changes. Getting both right requires per-route cache headers.
Why it matters
Repeat-visit performance is much worse without caching. A returning user re-downloads 1-2MB of static assets they already have, blowing past 1 second of unnecessary load time on mobile. Conversion-funnel pages (cart, checkout) are particularly hurt because users hit them multiple times in a session.
CDN cache hit rate also depends on Cache-Control. A CDN won't cache responses without explicit max-age (or with no-store/private). Low cache hit rate means every request hits your origin, which compounds TTFB issues and origin egress costs.
Common causes
- Default web server config (Express, plain nginx) without Cache-Control middleware.
- Cache-Control: no-store accidentally applied to static assets.
- max-age values too short (under 1 hour) for content that doesn't change.
- Hashed asset filenames not used, forcing short cache lifetimes for safety.
- API responses cached aggressively when they shouldn't be (private user data).
- stale-while-revalidate not set, causing re-validation latency on every request.
Detect this on your site
Run a quick scan with the Speed Test. The tool surfaces this exact issue with the records and context needed to apply the fix below.
Open Speed TestHow to fix it
- 1
Audit current Cache-Control headers
Run Lighthouse's "Serve static assets with an efficient cache policy" audit. It lists every asset with its current cache lifetime and the savings from extending it. DevTools Network panel also shows headers per response.
- 2
Long cache for hashed static assets
Assets with content hashes in the filename (app.4f7d2.js, hero.a3b9c.webp) can safely cache forever. Set Cache-Control: public, max-age=31536000, immutable. The immutable directive tells browsers not to revalidate even on reload.
- 3
Stale-while-revalidate for HTML
HTML needs to be reasonably fresh but doesn't need to be perfectly current. Cache-Control: public, s-maxage=60, stale-while-revalidate=600 caches at the edge for 60 seconds, then serves stale while revalidating in the background for up to 10 minutes.
- 4
private no-store for sensitive data
User-specific responses (logged-in pages, API responses with personal data) need Cache-Control: private, no-store. private prevents shared caches (CDN) from caching; no-store prevents the browser too. Don't ship sensitive data with public cache headers.
- 5
Configure cache headers per route in next.config.js
Next.js: use the headers() export in next.config.js or the response headers in route handlers to set per-path Cache-Control. Vercel and Netlify also support per-path rules in vercel.json / netlify.toml.
- 6
Use ETags or Last-Modified for revalidation
When max-age expires, the browser sends If-None-Match (ETag) or If-Modified-Since (Last-Modified). The server returns 304 Not Modified for unchanged content, sparing the actual response body. nginx and Express set ETags by default.
- 7
Verify cache hits in production
After deploy, check the Cache-Control headers via curl -I and DevTools. CDNs add their own cache-status header (CF-Cache-Status, X-Cache) showing HIT/MISS. Aim for 90%+ HIT rate on static asset paths.
Example
# next.config.js
module.exports = {
async headers() {
return [
{
source: "/_next/static/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/images/:path*\\.(jpg|jpeg|png|webp|avif|svg)",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/blog/:slug*",
headers: [
{ key: "Cache-Control", value: "public, s-maxage=60, stale-while-revalidate=600" },
],
},
{
source: "/api/me",
headers: [
{ key: "Cache-Control", value: "private, no-store" },
],
},
];
},
};Per-path Cache-Control: long for hashed assets, SWR for HTML, no-store for private.
Frequently asked
max-age applies to all caches (browser + CDN); s-maxage applies only to shared caches (CDN). Use s-maxage for HTML so the CDN caches longer than the browser allowing fast revalidation on origin updates.
Yes. immutable tells the browser not to revalidate even on reload. Because the filename changes when the content changes the cache key uniquely identifies the content. Safe and recommended for /static/ paths.
ETag is more precise (content-based hash) but adds CPU cost to compute. Last-Modified (file mtime) is cheaper but coarser. Most servers default to both — keep both unless ETag computation is a bottleneck.
Related fixes
Page speed, redirects, Core Web Vitals
Use a CDN to cut latency and offload origin traffic
Page speed, redirects, Core Web Vitals
Enable Brotli or gzip to compress text assets
Page speed, redirects, Core Web Vitals
Fix TTFB slow: cut Time to First Byte under 800ms
Page speed, redirects, Core Web Vitals
Optimize images: AVIF, WebP, srcset, and modern formats