Published on

HTTP Caching Headers, A Practical Guide

Authors

HTTP caching is one of those things you can ignore for a long time without consequence, until suddenly your users are seeing stale data or your CDN isn't caching anything and you have no idea why. The headers are simple once you understand the model.

Two Types of Caches

Caches sit between the client and the server:

  • Private cache: the browser's local cache, stores responses for a single user
  • Shared cache: a CDN or reverse proxy, stores responses for all users

Some responses should only be cached privately (user-specific data). Some can be cached publicly (shared assets). The headers let you control this.

Cache-Control

Cache-Control is the primary caching header. It's a comma-separated list of directives:

Cache-Control: public, max-age=3600

The most important directives:

DirectiveMeaning
publicCan be stored in shared caches (CDNs)
privateOnly stored in the browser's private cache
max-age=NCache is fresh for N seconds
s-maxage=NOverrides max-age for shared caches only
no-cacheMust revalidate with server before using cached response
no-storeNever cache this response anywhere
immutableContent will never change; skip revalidation

A common mistake: no-cache does NOT mean "don't cache". It means "cache it, but always check with the server before using it." no-store is what you want if you genuinely never want the response cached.

Static Assets

For CSS, JS, images, anything with a content hash in the filename:

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

One year, public, and immutable tells browsers not to bother revalidating (the URL will change if the content changes). This is the most aggressive caching you can do, and it's correct for hashed assets.

API Responses

For user-specific data:

Cache-Control: private, max-age=0, no-cache

For shared, public data that can be stale for a minute:

Cache-Control: public, s-maxage=60

Revalidation with ETag

When a cached response expires, the browser doesn't necessarily make a full request. It can revalidate: ask the server if the content has changed, and only transfer the body if it has.

The server sends an ETag (entity tag), an opaque identifier for the version of the resource:

HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: max-age=60

When the cache expires, the browser sends a conditional request:

GET /api/users/42 HTTP/1.1
If-None-Match: "abc123"

If the resource hasn't changed, the server responds with 304 Not Modified and no body, saving bandwidth. If it has changed, a full 200 response with the new content and a new ETag.

Last-Modified

An older alternative to ETag. The server sends:

Last-Modified: Tue, 11 Jul 2023 12:00:00 GMT

The browser revalidates with:

If-Modified-Since: Tue, 11 Jul 2023 12:00:00 GMT

Prefer ETag when possible, it's more precise (timestamps can have sub-second changes that Last-Modified misses) and handles situations where content is regenerated but unchanged.

Vary

Vary tells caches that the response varies depending on request headers. Without it, a CDN might serve a gzip-compressed response to a client that doesn't accept gzip:

Vary: Accept-Encoding

Be careful with Vary: the more headers you list, the more cache entries get created. Vary: Accept-Encoding is fine. Vary: Cookie effectively disables CDN caching since every cookie value creates a separate cache entry.

A Practical Decision Tree

Is the content user-specific?
  YesCache-Control: private, no-cache (+ ETag for revalidation)
  No    Does the URL change when content changes (content hash)?
      YesCache-Control: public, max-age=31536000, immutable
      No        How stale can it be?
          NeverCache-Control: no-store
          A few seconds/minutes → Cache-Control: public, max-age=N, s-maxage=N
          On-demand → Cache-Control: public, no-cache (+ ETag)

Debugging

Chrome DevTools → Network tab → click a request → Headers. Look for:

  • Cache-Control in the response headers
  • ETag or Last-Modified in the response
  • If-None-Match or If-Modified-Since in the request (revalidation)
  • The size column: (memory cache), (disk cache), or 304 tell you which cache was hit

Getting caching right doesn't require anything fancy, just intentional header choices that match your actual requirements for freshness and scope.