- Published on
HTTP Caching Headers, A Practical Guide
- Authors

- Name
- Alex Peng
- @aJinTonic
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:
| Directive | Meaning |
|---|---|
public | Can be stored in shared caches (CDNs) |
private | Only stored in the browser's private cache |
max-age=N | Cache is fresh for N seconds |
s-maxage=N | Overrides max-age for shared caches only |
no-cache | Must revalidate with server before using cached response |
no-store | Never cache this response anywhere |
immutable | Content 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?
Yes → Cache-Control: private, no-cache (+ ETag for revalidation)
No →
Does the URL change when content changes (content hash)?
Yes → Cache-Control: public, max-age=31536000, immutable
No →
How stale can it be?
Never → Cache-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-Controlin the response headersETagorLast-Modifiedin the responseIf-None-MatchorIf-Modified-Sincein the request (revalidation)- The size column:
(memory cache),(disk cache), or304tell 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.