Skip to main content
All fixes

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 Test

How to fix it

  1. 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. 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. 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. 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. 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. 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. 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