Cache-Control headers explained

Cache-Control is the HTTP header that tells browsers and CDNs whether they may store a response and for how many seconds. For files with a hash in the name, use public, max-age=31536000, immutable.

Updated 6 min read

Learning objectives

After reading this article you will be able to:

  • Understand what max-age, no-cache, no-store and immutable really do
  • Pick the right Cache-Control for HTML, assets, images and API responses
  • Set the header on a Node.js server and check the result with curl
On this page

Every time you open a website, the browser decides whether to download the files again or reuse a stored copy. The server makes that call through the Cache-Control header. Set well, it makes the site faster and cuts load on your server. Set badly, it leaves visitors looking at an old version for days.

If the CDN is still a black box to you, read what a CDN is first.

freshstalereplacedBrowserCDNOriginmax-agenot askedETag304200new copy
A fresh copy comes straight from the cache; a stale one is revalidated by ETag and gets a 304, or the new file with a 200.

What does Cache-Control do?#

Cache-Control is an HTTP response header (and a request header too). It carries a comma-separated list of directives that apply to every cache on the path: the browser's (private, single user) and shared ones such as CDNs and reverse proxies. The rules are in RFC 9111.

http
HTTP/2 200
content-type: text/css
cache-control: public, max-age=31536000, immutable

With no freshness information at all, a cache may estimate a lifetime on its own (heuristic caching), usually a fraction of the time since the last modification. So the rule is: state what you want instead of leaving the choice to the cache.

Which directives matter day to day?#

  • max-age=N: the response is fresh for N seconds, counted from when the server generated it. Fresh means the cache serves it without asking the server anything.
  • s-maxage=N: the same, but only for shared caches (CDNs). It overrides max-age there, and browsers ignore it.
  • no-cache: may be stored, but must be revalidated with the server before every reuse. It does not mean "do not cache".
  • no-store: no cache may store the response. Use it for sensitive data.
  • public and private: private limits storage to the user's browser cache; public allows shared caches, including for requests with Authorization.
  • immutable: the content will not change while fresh, so the browser does not even revalidate on reload. Only makes sense when the URL changes along with the content.
  • must-revalidate: once stale, the response is never served without revalidating. max-age=0, must-revalidate is effectively no-cache.
  • stale-while-revalidate=N: once stale, the cache may serve the old copy for N more seconds while it fetches a new one in the background.
  • stale-if-error=N: if the server answers with an error (500, 502, 503 or 504), the cache may serve the old copy for up to N seconds.

The most common mistake is assuming no-cache prevents storage. no-store does that.

How does revalidation work?#

When a copy goes stale, the cache does not have to download the whole file again. It asks the server whether anything changed by sending If-None-Match with the ETag it received (or If-Modified-Since with Last-Modified). If nothing changed, the server answers 304 Not Modified with no body, and the copy becomes fresh again. If it changed, the server answers 200 with the new file.

This is the mechanism behind no-cache: the browser always makes a round trip, but most of the time it gets back only a 304 a few bytes long.

Which Cache-Control should you use for each file type?#

Response type Header Why
JS, CSS and images with a hash in the name (app.3f9a1c.js) public, max-age=31536000, immutable The URL changes when the content changes, so it can stay for a year
index.html and pages that point to assets no-cache Always revalidates and picks up the new file names
Image or file without a hash in the name public, max-age=86400 (or less) Accepts being stale for a known time
API response personalized per user private, no-cache Never goes into a shared cache
Sensitive data (token, statement) no-store Must not be stored anywhere
Public content that can lag a few minutes max-age=60, stale-while-revalidate=300 Instant response, refreshed in the background
Different TTL on the CDN and the browser max-age=60, s-maxage=3600 The CDN keeps it 1 hour, the browser 1 minute

The logic of the first two rows matters most: HTML never stays cached for long and versioned files stay for a year. Each deploy produces new names, the new HTML points to them and nobody sees an old version. Build tools such as Vite already put the hash in the file name. More in what a static site is.

How do you set it in Node.js (Express)?#

express.static accepts the maxAge and immutable options. With setHeaders you change the header per file. Example with Express 5:

js
import express from "express";

const app = express();

// Files with a hash in the name: one year, immutable.
app.use(
  "/assets",
  express.static("dist/assets", { maxAge: "1y", immutable: true }),
);

// Everything else (index.html etc.): always revalidate.
app.use(
  express.static("dist", {
    setHeaders(res) {
      res.setHeader("Cache-Control", "no-cache");
    },
  }),
);

// API with user data: never in a shared cache.
app.get("/api/me", (req, res) => {
  res.set("Cache-Control", "private, no-cache");
  res.json({ name: "Ana" });
});

app.listen(process.env.PORT ?? 3000);

The first app.use answers with Cache-Control: public, max-age=31536000, immutable. The idea is the same on other servers: in Nginx, use add_header Cache-Control "public, max-age=31536000, immutable"; inside location /assets/.

How do you check that it worked?#

Request only the headers with curl -I and look at cache-control:

bash
curl -sI https://your-domain.com/assets/app.3f9a1c.js | grep -i -E "cache-control|^age|etag"

If a CDN is on the path, an age header above zero means the response came from its cache. In the browser, the Network tab of the developer tools shows "(disk cache)" or "(memory cache)" when the local copy was reused. Careful: reloading with F5 or Ctrl+Shift+R sends max-age=0 or no-cache in the request, so test by opening the URL in a new tab.

Which mistakes hurt the most?#

  • Long max-age on a file with no hash. The visitor keeps the old CSS until it expires. Use a long lifetime only with versioned URLs.
  • Long max-age on HTML. Even with new assets, the browser does not fetch the HTML that points to them.
  • Using no-cache expecting it to block caching. That is what no-store is for.
  • Caching a personalized response as public. A CDN may hand one user's data to another. Use private.
  • Mixing conflicting directives. private, no-cache, no-store, max-age=0, must-revalidate is equivalent to no-store; pick one intent.
  • Forgetting the CDN cache after a fix. Changing the header does not clear what was already stored; you need a purge or to wait for expiry.

How to do this on Square Cloud#

On Square Cloud, every plan includes a global CDN that respects the Cache-Control your application sends. This guide's recipe works as is: a hash in asset names with a one-year max-age, no-cache on the HTML, and private or no-store on anything that belongs to one user. You set the headers in code, as in the Express example, deploy, and check with curl -I on the *.squareweb.app subdomain every web application gets with HTTPS.

When a wrong file is already cached, use cache purge, available on the squareweb.app subdomain and on a custom domain (from the Standard plan), from the dashboard or the VS Code extension. It clears the cache for the whole host, so treat it as a one-off fix and let versioned URLs handle everyday deploys.

For files your users upload, Blob Storage lets you set Cache-Control per object and cache rules per folder, without going through your application. See purge in custom domains and the CDN in network.

Network

Custom domains, automatic TLS and a global CDN for your app

Learn more

FAQs

What is the difference between no-cache and no-store?

no-cache lets a cache store the response but forces it to revalidate with the server before reusing it. no-store forbids any cache from storing the response. For sensitive data, use no-store.

What is the maximum value for max-age?

There is no practical limit you need to memorize: 31536000 seconds (one year) is the usual value for versioned files. Negative or decimal values have undefined behavior.

What is s-maxage?

It is a freshness time that applies only to shared caches such as CDNs and proxies. It overrides max-age there, and browsers ignore it.

Why does the browser not pick up the new version of my file?

Usually the file was served with a long max-age and the URL did not change. Rename the file with a content hash, or lower the max-age of files that change often, such as HTML.

Next articleHow to use a custom domain for your appTo use a custom domain, add it to your host and create the DNS records the host gives you. Learn the steps, www vs root, HTTPS and common errors.

More in Networking and domains

See all 27 articles