A fast server can still deliver a slow website if every repeat visit downloads the same logo, fonts, CSS, JavaScript, and product photos again.

The fix sounds simple: cache everything. That advice causes a different mess. Customers see last week’s prices, a repaired JavaScript file stays broken in their browser, or a private account page gets stored where it shouldn’t.

Good caching isn’t one switch. It’s a policy that answers three questions for every response:

  1. May a browser or shared cache store this?
  2. How long may it reuse the stored response without checking the server?
  3. How will a changed file get a new URL or be revalidated?

This guide provides a working policy for those decisions. It is meant to be copied, adapted, and handed to a developer, host, or CDN vendor as acceptance criteria.

The short version: a cache policy matrix

Start here. The table is intentionally conservative for a typical business website.

Response typeRecommended starting headerWhy
Versioned CSS and JavaScriptpublic, max-age=31536000, immutableThe filename changes when the content changes
Versioned images and fontspublic, max-age=31536000, immutableLong reuse is safe when URLs are content-versioned
Unversioned static filespublic, max-age=3600, must-revalidateLimits how long an old file can persist
Public HTMLno-cacheMay store, but must validate before reuse
Frequently updated public API datapublic, max-age=60, stale-while-revalidate=300Brief freshness plus faster background refresh
Personalized HTML or API dataprivate, no-cacheBrowser-only storage with validation
Sensitive responsesno-storeDo not store the response
Redirects during a migrationShort TTL first, then extend after verificationA cached redirect can be hard to undo

These aren’t universal values. A live inventory count may tolerate 30 seconds while an employee directory can tolerate a day. The policy needs a named owner who can state the acceptable age for each type of information.

What Cache-Control actually controls

Cache-Control is the main HTTP response header for browser and intermediary caching. MDN’s directive reference separates cacheability, freshness, revalidation, and other controls. Combining directives without understanding those categories is how contradictory policies get deployed.

max-age sets a freshness lifetime

max-age=3600 means the response remains fresh for 3,600 seconds after it was generated. A cache can normally reuse a fresh response without contacting the origin server. The age is measured from response generation, not from the moment a browser first receives it, as explained in the MDN max-age documentation.

This is where business requirements become technical numbers. If a warehouse quantity can be five minutes old, the freshness lifetime must not exceed five minutes unless another purge mechanism is dependable.

public and private decide who may store it

public allows a response to be stored by shared caches such as CDNs. private restricts storage to a private cache, usually the visitor’s browser. MDN warns that private does not provide message confidentiality, so sensitive content still needs HTTPS and appropriate authorization controls.

A logged-in dashboard is usually private. A product category page may be public if every visitor receives the same response. Don’t label a personalized response public just to improve a cache-hit report.

no-cache does not mean “do not cache”

This name trips up experienced teams. no-cache allows storage, but requires the cache to validate the response with the origin before reusing it. MDN recommends no-cache when content should be stored but must be revalidated before each reuse.

That makes it a practical baseline for HTML. The browser may keep the document and send a conditional request. If nothing changed, the server can answer 304 Not Modified without resending the body.

no-store means do not store

Use no-store for responses that should not be written to browser or intermediary caches. Examples include pages containing highly sensitive account details and some transaction responses. It is not a general performance setting. MDN specifically cautions that broadly adding no-store sacrifices caching advantages and that clearing previously stored responses requires a separate mechanism.

immutable makes fingerprinted files cheaper

When a file URL changes with its contents, such as /app.a84f91.js, there is no reason for a browser to ask whether that exact URL changed. The immutable directive tells the cache the response will not be updated while fresh. MDN describes this as a way to avoid unnecessary conditional requests, especially during reloads.

The filename promise matters. Never send a year-long immutable policy for /app.js if deployments overwrite /app.js in place.

s-maxage gives a CDN a separate clock

s-maxage applies to shared caches and overrides max-age or Expires for them. A response such as public, max-age=60, s-maxage=3600 can remain fresh at the CDN for an hour while a browser treats its copy as fresh for one minute. See the MDN s-maxage behavior.

This is useful when the CDN can purge quickly and the browser cannot. It also creates two freshness timelines, so document both.

The safest pattern: version the URL, then cache for a year

Long caching works best when the URL identifies the file’s contents. Build tools commonly add a hash to filenames:

/assets/site.48c9e1.css
/assets/app.f3a107.js
/images/hero.82b519.webp

When the CSS changes, the build emits a different filename and HTML points to the new URL. The old file can remain cached because no current page asks for it. Web performance guidance from web.dev recommends long-lived caching for versioned URLs and revalidation for resources that don’t change their URLs.

For fingerprinted assets, use:

Cache-Control: public, max-age=31536000, immutable

That one-year value is 31,536,000 seconds. The number isn’t magic. It simply means the cache lifetime is long enough that file naming, not expiration, handles ordinary releases.

Check the entire dependency chain before using it. A hashed CSS file may reference an unversioned font or background image. An HTML page may contain a hard-coded path to an asset that deployments overwrite. One unversioned link can defeat the policy.

HTML needs a different policy

HTML usually points visitors to the current asset filenames, prices, forms, and navigation. Serving an old document can keep a user on an old release even when every asset is perfectly versioned.

For ordinary public HTML, a safe starting point is:

Cache-Control: no-cache

Pair it with a validator. An ETag identifies a representation, while Last-Modified provides its modification time. With either validator, a browser can make a conditional request using If-None-Match or If-Modified-Since. If the representation is unchanged, the server returns 304 Not Modified. MDN’s conditional request guide explains the full exchange.

Some sites can cache public HTML briefly at the CDN:

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=30

That permits a shared cache to serve the page for five minutes while browsers immediately revalidate. stale-while-revalidate permits temporary reuse of a stale response while the cache refreshes it in the background, as defined in MDN’s Cache-Control reference.

Do this only when public HTML is truly identical for everyone. Cookie-driven pricing, location messages, account status, cart contents, and A/B tests can create variations. If the cache key does not include the right inputs, one visitor may receive another visitor’s version.

APIs, forms, and logged-in pages

An API endpoint needs a data-age rule, not a blanket “APIs are dynamic” exemption.

A public list of office locations might use:

Cache-Control: public, max-age=300, stale-while-revalidate=3600

A logged-in account response might use:

Cache-Control: private, no-cache

A sensitive confirmation response might use:

Cache-Control: no-store

Authentication alone does not prove a response is uncacheable, but shared caching of authorized responses needs deliberate controls. The HTTP caching specification explains that responses to requests containing Authorization are not normally reusable by a shared cache unless directives explicitly permit it.

Form pages and form submissions also differ. The page containing a contact form may be cached like other HTML. The POST response often contains unique confirmation data and should not be treated like a static page. Test the full submit, refresh, back-button, and retry sequence.

CDN rules can override the origin

Many caching bugs are not in application code. A hosting platform, reverse proxy, WordPress plugin, or CDN may rewrite the header, ignore it, or apply a page rule by URL pattern.

Cloudflare, for example, documents that its Browser Cache TTL setting can override lower origin values in some configurations, while Edge Cache TTL controls how long content remains in Cloudflare’s cache. Other platforms use different names for the same layers.

Write down the source of truth for each rule:

  • Application or framework
  • Web server or reverse proxy
  • CDN edge policy
  • Browser-facing header

That is one of only three lists in this guide because it deserves attention. If two systems own the same policy, the effective result can change during a routine hosting update.

Also understand Vary. A server can use it to indicate which request headers influenced the response, and caches use those values as part of selection. MDN’s Vary reference warns that Vary: * prevents reuse and should be used carefully. Vary: Accept-Encoding is common. Varying on high-cardinality values such as an entire cookie header can destroy the cache-hit rate.

Copy-ready server examples

These examples assume asset fingerprinting. Adapt paths and test them in staging.

For Nginx:

location ~* \.(css|js|png|jpg|jpeg|gif|svg|webp|avif|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location / {
    add_header Cache-Control "no-cache";
}

For Apache:

<FilesMatch "\.(css|js|png|jpe?g|gif|svg|webp|avif|woff2)$">
  Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

<FilesMatch "\.html$">
  Header set Cache-Control "no-cache"
</FilesMatch>

For an application response:

response.setHeader("Cache-Control", "private, no-cache");

Don’t paste an extension rule onto a system that serves unversioned uploads at stable URLs. If a business replaces /menu.pdf or /price-list.pdf without changing the filename, a year-long immutable rule can leave customers with an old document. Either version the URL or assign that directory a short lifetime with revalidation.

How to audit a live caching policy

Use real response headers, not a control-panel screenshot.

  1. Inventory representative URLs: home page, product or service page, hashed CSS, hashed JavaScript, logo, web font, PDF, public API response, login page, and authenticated response.
  2. Run curl -I https://example.com/path for each public URL. Record Cache-Control, Age, ETag, Last-Modified, Expires, Vary, and any CDN status header.
  3. Request each URL twice. A CDN’s Age or cache-status header should show whether the second request was served from cache. Header names differ by provider.
  4. Change a staging asset and deploy it. Confirm the HTML references a new fingerprinted URL and the old URL still returns its old contents.
  5. Change staging HTML. Confirm a browser receives or validates the new document within the promised interval.
  6. Test logged-in and logged-out sessions. Inspect headers and response bodies for personalization leaks.
  7. Test purge and rollback procedures before an emergency. Record who can run them.

Chrome DevTools is useful, but don’t check “Disable cache” and then use that session to judge production caching. The browser’s Network panel shows transfer source and response headers. A command-line request provides a cleaner baseline, while a real browser catches service workers and request variations.

Seven expensive caching mistakes

Treating no-cache as no-store. The names are misleading. Use the directive that matches the actual storage requirement.

Caching stable filenames forever. If content changes at the same URL, immutable blocks the normal route to discovering the update while the response is fresh.

Caching HTML and assets identically. HTML is the release map. Assets are good candidates for long storage after fingerprinting.

Ignoring the CDN layer. The origin header may not be the header a visitor receives. Inspect the public response.

Caching personalized content as public. A fast data leak is still a data leak. Test with multiple accounts and cookie states.

Using purge as the only release strategy. Purges fail, credentials expire, and cache nodes can behave differently. Versioned URLs make releases less dependent on a perfect global purge.

Setting a policy without an owner. Product availability, legal copy, campaign pages, and application bundles have different tolerance for stale content. Someone must approve those limits and revisit them when the site changes.

A caching policy you can put in a project contract

Use the following as acceptance criteria:

Production HTML will be revalidated before reuse unless a documented route has an approved shared-cache lifetime. Build-generated static assets will use content-versioned filenames and a one-year public immutable cache policy. Personalized responses will not be stored by shared caches. Sensitive responses will use no-store. The team will verify representative response headers at the public CDN edge, test a release and rollback, document purge access, and assign an owner for each exception.

That paragraph is more useful than “install a caching plugin.” It defines the outcome while leaving room for the developer to choose the implementation.

Frequently asked questions

What is the best Cache-Control header for a website?

There isn’t one header for an entire site. Use long-lived public and immutable caching for fingerprinted static assets, revalidation for HTML, private for personalized responses, and no-store for content that must not be stored.

Should HTML be cached?

HTML can be stored and revalidated with no-cache. Brief CDN caching can work for identical public pages, but teams must account for personalization and define an acceptable stale period.

Are ETags better than Last-Modified?

An ETag can identify changes more precisely than a timestamp. Last-Modified is simpler and can be enough. Either can support conditional requests, and servers often provide both. Avoid unstable ETags that change across otherwise identical server nodes.

How do I clear a visitor’s browser cache after a deployment?

Don’t make routine releases depend on clearing it. Change asset URLs when their contents change. For HTML and unversioned resources, use short freshness or revalidation. A CDN purge only clears the CDN and does not reliably erase copies already stored in visitor browsers.

Does caching improve Core Web Vitals?

Caching can reduce transfers and repeat-load work, but it does not repair oversized images, render-blocking code, slow server generation, or poor interaction handling. It is one part of performance engineering, not a substitute for measuring real users.

Make the policy survive the next release

The strongest caching setup isn’t the one with the longest TTL. It’s the one where URLs change predictably, freshness matches the business risk, private data stays private, and the team has tested what happens when a release goes wrong.

If your site has conflicting cache rules, stale content, or performance work that never seems to stick, talk with YourWebTeam. We’ll trace the response from the application through the CDN and build a policy your team can maintain.