ETags and Cache-Control for APIs: The Headers Most Teams Skip
I once spent a morning staring at a dashboard that made no sense. The service was handling a few thousand requests a minute, and a large slice of the responses were byte-for-byte identical to what the same client had received eight seconds earlier. We were paying CPU, bandwidth, and database connections to say exactly the same thing, over and over.
The fix wasn't a bigger machine. It was three HTTP headers that most API teams never send.
This is the practical version of what I learned: how conditional requests actually work, which cache directives matter for JSON APIs (and which ones silently break your auth), and the one header — Vary — that causes caching bugs you only find in production.
Everything below is plain HTTP. It works in FastAPI, Express, Rails, Go, Django, whatever. If your framework sets these automatically, good — but you should still know what it's setting.
Why caching an API is not caching a web page
When people say "caching," they usually mean one of two very different things:
- Server-side caching. You store the computed result (or the database row) somewhere fast — Redis, memcached, an in-process LRU — and skip the expensive work. The response still goes out over the wire in full.
- Client-side / intermediary caching. The client (a browser, a mobile app, a CDN, a reverse proxy) keeps a copy of the response and decides not to ask again, or to ask conditionally.
Most teams do #1 and stop. That leaves #2 on the table, and #2 is where the cheap wins are: you save the entire round trip, the serialization, and the bandwidth.
The mechanism for #2 is the conditional request. It's two headers and a status code.
ETag + If-None-Match: the 304 you're not sending
Here's the flow.
- Client GETs
/v1/users/42. Your server returns the body plus anETagheader — an opaque identifier for that specific representation:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "a3f9c1e8"
{"id": 42, "name": "Ada", "role": "admin"}
- Next time the client needs that resource, it sends the tag back:
GET /v1/users/42
If-None-Match: "a3f9c1e8"
- If nothing changed, you return 304 Not Modified with no body:
HTTP/1.1 304 Not Modified
ETag: "a3f9c1e8"
The client reuses its copy. You saved the serialization, the payload, and the network transfer. On a 20 KB JSON document, that's 20 KB you didn't send — and a client that reuses its own copy instead of parsing a new one.
Generating an ETag that doesn't lie
The first trap: people hash the response body after they've built it. That's fine for correctness, but it means you did all the work anyway. The bigger trap is picking a tag that changes when the content doesn't, which turns every conditional request into a miss.
Two approaches that hold up:
-
Weak ETags from a version column. If your row has
updated_ator a monotonicversion, use it:ETag: W/"user-42-v17". Cheap, correct, no hashing. TheW/prefix says "semantically equivalent," which is exactly what you mean. - Strong ETags from a content hash. Hash the canonical serialized bytes. Correct, but costs a hash per response — usually fine (sha1 on a few KB is microseconds), and it catches changes your version column missed, such as a joined table updated independently.
If you serve the same resource in multiple formats — JSON and CSV, say — the ETag must differ per representation. Otherwise a client can cache the CSV, send If-None-Match back to your JSON endpoint, and get a 304 for the wrong body.
Cache-Control: the directives that actually matter
ETag makes revalidation cheap. Cache-Control decides when revalidation happens at all.
For APIs the useful set is small:
| Directive | What it does | Use it for |
|---|---|---|
no-store |
Nothing may be cached, ever | Anything with secrets in the body |
no-cache |
May cache, must revalidate every time | Data that changes often but benefits from 304s |
private |
Only the end client may cache, not shared proxies | Per-user responses |
public |
Shared caches (CDN) may store it | Genuinely public reference data |
max-age=N |
Fresh for N seconds, no revalidation | Slowly changing reference data |
s-maxage=N |
max-age for shared caches only |
CDN tuning |
stale-while-revalidate=N |
Serve stale for N seconds while refreshing | Read-heavy, latency-sensitive endpoints |
A sane default for an authenticated JSON API:
Cache-Control: private, no-cache, max-age=0
That reads as: "the client may keep a copy, but every request must be revalidated." Combined with ETag, you get 304s when nothing moved and full bodies only when something did. You get most of the benefit with none of the staleness risk.
Do not put public on anything behind auth. public means any intermediary — corporate proxies, CDNs, your own gateway — may store it and hand it to the next caller. That's how one user's data ends up in another user's response.
The Vary header, and why auth breaks caching
This is the one that bites in production.
HTTP caches key responses on the URL by default. But your response often depends on more than the URL — it depends on request headers. Vary tells caches which headers to include in the cache key:
Vary: Accept-Encoding, Accept, Authorization
If you serve different bodies based on Accept (JSON vs protobuf) or on the caller's permission level, and you don't declare that in Vary, a shared cache will happily serve one caller's representation to another. This is a real, known class of data-leak bug, not a theoretical one.
Some practical rules:
- For per-user authenticated responses, the simplest safe answer is
Cache-Control: private. Shared caches see it and refuse to store. That makesVary: Authorizationlargely moot for shared caches — but still declare it if you rely on client-side caches. - Never key a cache on
Authorizationand mark the responsepublic. That combination is contradictory and different CDNs handle it differently. -
Vary: Accept-Encodingis set by most servers automatically. Don't override it away.
Invalidation on writes
Conditional requests only stay honest if the ETag changes exactly when the resource changes. Two patterns work:
Version-based. Bump a version integer, or touch updated_at, on every write — inside the same transaction. The ETag derives from it. Simple, predictable, and it survives multiple app servers because the source of truth is the database row.
Hash-based. The ETag derives from the response content, so it changes automatically. Correct with zero bookkeeping, but you only discover the change after you've built the response — savings on the read path only.
Either way: invalidate on the write that changes the representation, not on a timer. A TTL that expires faster than your data changes just moves the cost around; a TTL slower than your data changes serves stale data.
A minimal FastAPI example
import hashlib
from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
# Pretend this is a database row
USERS = {42: {"id": 42, "name": "Ada", "role": "admin", "version": 17}}
def make_etag(payload: dict) -> str:
body = repr(sorted(payload.items())).encode()
return '"' + hashlib.sha1(body).hexdigest()[:16] + '"'
@app.get("/v1/users/{user_id}")
def get_user(user_id: int, request: Request):
user = USERS.get(user_id)
if user is None:
raise HTTPException(status_code=404)
etag = make_etag(user)
# Conditional request: the client already has this representation.
if request.headers.get("if-none-match") == etag:
return Response(
status_code=304,
headers={"ETag": etag, "Cache-Control": "private, no-cache"},
)
return JSONResponse(
user,
headers={
"ETag": etag,
"Cache-Control": "private, no-cache, max-age=0",
"Vary": "Accept-Encoding, Authorization",
},
)
Two things worth calling out. First, the 304 path still returns the ETag — clients need it to keep their copy validated for next time. Second, Cache-Control: private appears on both paths; a 304 without it can be stored by a shared cache under the wrong assumptions.
What to measure before and after
If you want to know whether this is worth the effort, instrument three numbers:
-
304 ratio — 304s divided by total conditional requests. Under 20% and your ETag is probably churning on irrelevant fields. Near zero and you're not sending
ETagat all. - Bytes out per request — total response bytes divided by request count. Should drop noticeably on read-heavy endpoints.
- Origin CPU and DB queries — the server-side win. The 304 path should skip the expensive part of the handler wherever correctness allows.
On a polling-heavy endpoint in our stack — a mobile client checking for updates every few seconds — the 304 path replaced most full responses. The client still polls; it just gets a couple hundred bytes back instead of tens of kilobytes, and the origin skips the joins. Same correctness, much less work. The cheapest request is the one you never had to answer.
The short list
If you only do five things:
- Return
ETagon GET responses. Weak tags from a version column are enough to start. - Honor
If-None-Matchand return304with the sameETag. - Default authenticated endpoints to
Cache-Control: private, no-cache. - Declare
Varyfor anything the response depends on beyond the URL. - Change the ETag on the write, in the same transaction as the write.
None of this is exotic. It's just headers most teams never get around to sending — and the round trips they keep paying for.
Top comments (0)