Browser caching is the mechanism by which a client stores a copy of a fetched resource and reuses it on subsequent requests, governed entirely by HTTP response headers such as Cache-Control, ETag, and Last-Modified. When those headers are absent or misconfigured, the browser has no choice but to re-download every asset on every navigation, which taxes your server and directly degrades metrics like Largest Contentful Paint (LCP) and Time to First Byte (TTFB) on repeat visits.

This guide is not a tour of every caching directive in the HTTP specification. It is a troubleshooting checklist organized around the symptoms you will encounter when caching goes wrong, paired with the headers that fix them. Work through it in order if you are setting caching up from scratch, or jump to the symptom that matches your production issue.


Before You Start: The Two Caching Decisions

Every cacheable resource needs an answer to two separate questions, and confusing them is the root of most broken caching setups.

  1. Freshness: How long is this response considered valid without checking the server? This is controlled by Cache-Control: max-age and s-maxage.
  2. Revalidation: Once expired, how does the browser confirm the cached copy is still usable without re-downloading the full body? This is controlled by ETag / If-None-Match and Last-Modified / If-Modified-Since.

A resource with strong freshness but no validators will re-download on expiry even if nothing changed. A resource with validators but no freshness will trigger a conditional request on every load. The right configuration uses both, and the correct balance depends on whether the URL is versioned.


Symptom 1: “Every Visit Re-downloads Everything”

Cause

No Cache-Control header is being sent, or it is set to no-store. When the header is absent, browsers apply heuristic caching, which is unpredictable and often defaults to no caching for HTML. When it is no-store, nothing is retained at all.

Fix

Set an explicit Cache-Control on every static asset response. For content-hashed filenames (for example app.4f8a91.js), the file content can never change without the URL changing, so you can cache it aggressively.

# Nginx: hashed static assets
location ~* \.(js|css|woff2|png|jpg|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# HTML entry points must not be cached long-term
location ~* \.html$ {
    add_header Cache-Control "public, max-age=0, must-revalidate";
}

The immutable directive tells the browser never to revalidate the resource during its freshness lifetime, even if the user hits refresh. It is only safe for URLs whose contents are hash-versioned. Applying immutable to a non-hashed filename like /app.js is a common and painful mistake: users will keep the old file for a year because the URL never changes.

Verify

Open DevTools, go to the Network tab, reload, and click any asset. In the Headers panel look for Cache-Control in the response and (from disk cache) or (from memory cache) in the Size column on the second load. If you see a full transfer size again, the freshness window is not being applied.


Symptom 2: “Users See Stale Content After a Deploy”

Cause

Two possibilities. Either the HTML itself is being cached with a long max-age, so the browser keeps referencing old hashed asset URLs that no longer exist. Or assets are cached without content hashing, so the same URL serves an outdated body.

Fix

Separate the caching policy by resource type. HTML should never be cached for long windows, because it is the document that points at the current asset hashes.

# HTML: allow caching but force revalidation on every use
add_header Cache-Control "public, max-age=0, must-revalidate";
add_header ETag "\"$request_id-$mtime\"";

The must-revalidate directive instructs the browser to check with the origin once the resource is stale, rather than serving a stale copy when the network is offline. A max-age=0 combined with a strong ETag produces a conditional request that returns 304 Not Modified when nothing changed, costing only a few hundred bytes over the wire.

For assets, ensure your build pipeline emits content hashes into filenames. Webpack’s [contenthash], Vite’s default hashing, and Parcel all do this out of the box. If your bundler still emits /main.js, fix that before tuning headers — no header configuration can safely cache a URL that changes contents.

Verify

Deploy a change, then reload in an incognito window. The HTML should return 200 with the new asset hashes referenced, and each asset should return 200 on first load and (from disk cache) on refresh. If the HTML returns 304 but points at a deleted asset hash, your Cache-Control on the HTML is too long.


Symptom 3: “Conditional Requests Return 200 Instead of 304”

Cause

The server is not generating a stable ETag, or the ETag changes on every response even when the body is identical. Some frameworks (notably certain Express configurations and older Apache setups) include the inode or a timestamp in the ETag, which prevents any two responses from ever matching.

Fix

Use a content-derived ETag. For a static file server, the mtime-based or hash-based ETag is fine because the file only changes when deployed. For dynamically generated responses, hash the response body.

// Express: weak ETag based on response body hash
import crypto from "node:crypto";
import express from "express";

const app = express();
app.set("etag", "strong");

app.get("/api/config", (req, res) => {
  const body = JSON.stringify({ featureFlags: ["newCheckout"], version: 42 });
  const hash = crypto.createHash("sha1").update(body).digest("hex");
  res.set("ETag", `"${hash}"`);
  res.set("Cache-Control", "public, max-age=60, must-revalidate");
  res.send(body);
});

Note the max-age=60 here rather than a year. API responses are rarely safe to cache immutably because the underlying data can change while the URL stays fixed. A short freshness window lets clients serve cached responses during traffic bursts while still picking up server-side changes within a minute.

Verify

curl -sI https://example.com/api/config | grep -i etag
# Capture the ETag value, then:
curl -sI -H 'If-None-Match: "<etag-value>"' https://example.com/api/config
# Expected: HTTP/2 304

If you receive 200 instead of 304, the ETag is unstable. Log the value on two consecutive requests and diff them.


Symptom 4: “Sensitive Data Is Being Cached on Shared Proxies”

Cause

A response containing user-specific data is served with public in Cache-Control, allowing CDN edge nodes and shared proxies to store it and serve it to a different user.

Fix

For any authenticated or personalized response, use private and usually no-store.

location /account/ {
    add_header Cache-Control "private, no-store";
    add_header Vary "Authorization, Cookie";
}

private permits the end user’s browser to cache but forbids shared caches. no-store is stricter and prevents storage even in the browser, appropriate for tokens, session data, or anything that should not persist on disk. The Vary header is essential here: without it, a CDN may serve a cached authenticated response to an anonymous request that happens to share the same URL.

Verify

Use curl -I against your CDN hostname, not the origin, and confirm the Cache-Control value reflects the strict policy. CDNs such as Cloudflare and Fastly sometimes override origin headers depending on their page rules, so always test the public edge URL.


Symptom 5: “Cache Busting Changes Do Not Take Effect”

Cause

A CDN or service worker is caching the asset independently of the browser, and its cache is not invalidated when the version changes. The browser may be fetching a fresh copy, but the edge is serving a stale one.

Fix

This is the one scenario where header tuning alone is insufficient. You need a purge step tied to deploy. Most CDNs expose an API for this.

# Cloudflare: purge a specific URL after deploy
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"files":["https://example.com/index.html"]}'

Purge the HTML entry points on every deploy. Because assets are content-hashed, their URLs change with each release, so they do not need to be purged. Purging only the HTML is faster and avoids invalidating the entire edge cache, which would spike origin traffic.

Verify

After purging, fetch the URL with a header indicating a warm cache state, such as an If-None-Match or an age probe via curl -I, and confirm the Age header resets to a low number. A high Age value indicates the edge is still serving the pre-purge copy.


Quick Reference

SymptomLikely CauseHeader Fix
Assets re-download every visitMissing or no-store Cache-Controlpublic, max-age=31536000, immutable on hashed files
Stale content after deployHTML cached too long, or non-hashed URLsmax-age=0, must-revalidate on HTML; content-hash assets
200 instead of 304 on revalidationUnstable ETagGenerate ETag from response body hash
Private data on edge cachespublic on authenticated routesprivate, no-store plus Vary: Authorization, Cookie
Purge not reflected at edgeCDN holds its own copyPurge HTML URLs via CDN API on each deploy

When Not to Cache

Caching is not a default. Skip it entirely for payment confirmation pages, one-time token endpoints, and any response whose correctness depends on real-time server state. A user refreshing a checkout page must see the current state, not a cached one. For these routes, set Cache-Control: no-store explicitly rather than relying on the absence of a header, which may be overridden by proxy defaults.

Once the headers above are in place, validate the whole flow with Lighthouse’s “Serve static assets with an efficient cache policy” audit and confirm on a repeat visit that your LCP is measurably lower than the cold-load benchmark. That delta, not the header values themselves, is the real measure of whether your caching is working.