DEV Community

Sir Max
Sir Max

Posted on

ETags and Cache-Control for APIs: The Headers Most Teams Skip

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:

  1. 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.
  2. 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.

  1. Client GETs /v1/users/42. Your server returns the body plus an ETag header — an opaque identifier for that specific representation:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "a3f9c1e8"

{"id": 42, "name": "Ada", "role": "admin"}
Enter fullscreen mode Exit fullscreen mode
  1. Next time the client needs that resource, it sends the tag back:
GET /v1/users/42
If-None-Match: "a3f9c1e8"
Enter fullscreen mode Exit fullscreen mode
  1. If nothing changed, you return 304 Not Modified with no body:
HTTP/1.1 304 Not Modified
ETag: "a3f9c1e8"
Enter fullscreen mode Exit fullscreen mode

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_at or a monotonic version, use it: ETag: W/"user-42-v17". Cheap, correct, no hashing. The W/ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 makes Vary: Authorization largely moot for shared caches — but still declare it if you rely on client-side caches.
  • Never key a cache on Authorization and mark the response public. That combination is contradictory and different CDNs handle it differently.
  • Vary: Accept-Encoding is 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",
        },
    )
Enter fullscreen mode Exit fullscreen mode

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 ETag at 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:

  1. Return ETag on GET responses. Weak tags from a version column are enough to start.
  2. Honor If-None-Match and return 304 with the same ETag.
  3. Default authenticated endpoints to Cache-Control: private, no-cache.
  4. Declare Vary for anything the response depends on beyond the URL.
  5. 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)