Adding HTTP caching to an API comes down to three decisions: what your ETag actually represents, what your conditional requests return on the read path versus the write path, and which layer in the chain will actually read the Cache-Control you emit. The first decides whether concurrent writers silently clobber each other, the second decides whether you save a full response body, and the third decides whether per-user data ends up in a shared cache. The specifications are settled — RFC 9110 (STD 97) and RFC 9111 (STD 98) were both published in June 2022, obsoleting the RFC 723x series — so nearly everything that bites in production lives in the gap between the spec and what the intermediaries actually implement. The one genuinely recent addition is RFC 9875, HTTP Cache Groups, published October 2025.
Strong and weak ETags are not interchangeable
RFC 9110 §8.8.3.2 defines exactly two comparison functions. Strong comparison: two entity tags are equivalent if neither is weak and their opaque-tags match character by character. Weak comparison: equivalent if the opaque-tags match character by character, regardless of whether either or both carry the W/ prefix. The spec's own table makes the trap obvious — W/"1" versus "1" is no match under strong comparison but match under weak; and W/"1" versus W/"1" is also no match under strong comparison.
Which header uses which function is normative, not a matter of taste:
If-None-MatchMUST use weak comparison (§13.1.2), because weak tags are still valid for cache validation.If-MatchMUST use strong comparison (§13.1.1), because the client's intent is to abort if the representation data changed at all.
The immediate consequence: a W/"..." ETag can save you bandwidth but can never drive optimistic concurrency. A server comparing If-Match: W/"abc" under strong comparison will never match, so every conditional write returns 412 — and an integration suite that only exercises GET will never catch it.
There are three common ways to mint an ETag for an API resource:
- Hash the serialized body. Strong, but only if serialization is perfectly stable. Field ordering, float formatting, timestamp precision — any drift changes the ETag and the client re-downloads a body that is semantically identical.
- Derive it from a row version. A monotonic
versioninteger, orupdated_atcombined with the primary key. Strong, stable, cheap. - Mint a weak tag for semantically equivalent representations. Only needed when you genuinely do content negotiation — the same resource under different encodings or projections.
My recommendation is unambiguous: use strong ETags derived from a version column, and do not hash the response body. The problem with hashing isn't cost, it's ordering. You have to materialize the body before you can answer 304, and the entire point of a 304 is to not materialize it.
ETag: "42" # strong validator from a version column; usable with If-Match
ETag: W/"42" # weak validator; GET revalidation only
The precondition evaluation order is normative
RFC 9110 §13.2.2 specifies a six-step order with a MUST, on the reasoning that lost-update preconditions have stricter requirements than cache validation and that entity tags are presumed more accurate than date validators:
- Recipient is the origin server and
If-Matchis present — if false, respond 412, unless the state-changing request can be determined to have already succeeded. - Origin server, no
If-Match,If-Unmodified-Sincepresent — same, 412 on false. If-None-Matchpresent — if false, respond 304 for GET/HEAD and 412 for every other method.- Method is GET/HEAD, no
If-None-Match,If-Modified-Sincepresent — 304 on false. - GET with both
RangeandIf-Range— 206 or fall back to 200. - Otherwise, perform the method.
Three things routinely go wrong here. First, a failed If-None-Match on a PUT must return 412, not 304. Hand-rolled middleware often normalizes everything to 304, and most client libraries treat 304 as "unchanged, all good" — so the write silently never lands while the caller reports success. Second, If-Modified-Since is skipped entirely whenever If-None-Match is present. Sending both does not give you a fallback; it gives you the ETag path only. Third, steps 1 and 2 are explicitly scoped to "when recipient is the origin server" — intermediary caches do not evaluate If-Match, they forward it.
Four shapes of conditional write
- Unconditional PUT. Last writer wins, updates get lost. This is the default and it is wrong for anything with concurrent editors.
If-Match: "42". The standard pattern. The client echoes back the ETag it received from GET.If-Match: *. Asserts only that the resource currently exists. Use for update-only endpoints that must not create.If-None-Match: *. Asserts that the resource does not exist, for create-via-PUT. §13.1.2 calls this out explicitly as protection against a lost-update variant where several clients race to create the initial representation.
Two server-side details. §13.1.1 says the origin MAY signal failure with 412 — that MAY exists so an idempotent retry that can prove it already succeeded may return success instead — see The Idempotency-Key Header for how that replay detection is actually built. But 412 is the correct default, and the 412 response should carry the resource's current ETag so the client can re-derive and retry without an extra GET.
Separately: if your write endpoint requires If-Match, the response to a request that omits it should be 428 Precondition Required (RFC 6585, April 2012, Standards Track — the same RFC that defines 429, whose response-header conventions are covered in API Rate Limiting), not 400 and not 412. 428 means precisely "the origin server requires the request to be conditional," and the spec recommends the body explain how to resubmit successfully.
What a 304 is required to carry
RFC 9110 §15.4.5 requires the server generating a 304 to include any of these fields that a 200 to the same request would have carried: Content-Location, Date, ETag, Vary, plus Cache-Control and Expires. A 304 terminates at the end of the header section — no content, no trailers.
Omitting ETag is the common failure, and the obvious cost is that the client has no tag to send next time, so it degrades to full transfers. The less obvious cost is downstream. RFC 9111 §4.3.4 governs how a cache freshens stored responses on receiving a 304, and the first filter is: if the new response contains strong validators, only stored responses carrying one of those same strong validators are updated — and if none of the candidate set matches, the cache MUST NOT use the 304 to update anything at all. So a 304 without a stable ETag is inert as far as shared caches are concerned; the stored entry just keeps aging.
Worth noting the sibling rule in §4.3.5: a cache that issues a HEAD and gets a 200 whose validators or Content-Length disagree with the stored GET response SHOULD consider that stored response stale. HEAD is a real invalidation vector, not just a metadata probe.
Cache-Control directives, read carefully
no-cache does not mean "do not cache." §5.2.2.4: the unqualified form means the response MUST NOT be used to satisfy another request without forwarding it for validation and receiving a successful response. Storage is fine. The directive that prevents storage is no-store. Getting these backwards is the single most common Cache-Control error in API codebases.
private is not access control. It constrains shared caches only. The browser will still write the response to disk in the clear. Anything genuinely sensitive needs no-store.
The qualified forms are underused. no-cache="Set-Cookie" means a cache MAY reuse the response for a later request provided the listed fields are excluded or successfully revalidated. private="Set-Cookie" means a shared cache MUST NOT store the listed header fields, while the rest of the response remains storable (§5.2.2.4 and §5.2.2.7). For APIs that attach a per-user header to an otherwise shareable payload, this is exactly the right tool.
The Authorization back door is the one to internalize. RFC 9111 §3.5: a shared cache MUST NOT use a cached response to a request bearing an Authorization header to satisfy any subsequent request unless the response contains a directive that allows a shared cache to store it. The spec names three such directives: must-revalidate, public, and s-maxage.
must-revalidate being on that list is deeply counterintuitive. Someone adds must-revalidate to a Bearer-token-authenticated response reasoning that it forces validation on every request — and what they have actually done is flip the switch that permits a shared cache to store and cross-serve it. must-revalidate only forces validation after the response becomes stale; while fresh, the shared cache may hand that response to a different user, and freshness may come from Expires or, worse, from heuristics. If you want private, write private or no-store. Never reach for must-revalidate to express privacy.
Absent an explicit expiration, heuristics take over. §4.2.2: a cache MUST NOT use heuristics when an explicit expiration time is present, and when one is absent it may estimate freshness from Last-Modified — the spec suggests a fraction of the interval since that time, with 10% as a typical setting. An API response carrying only Last-Modified: <a year ago> and no Cache-Control can be treated as fresh for roughly 36 days by a conforming intermediary. Emit explicit Cache-Control on every response, including errors.
Two directives people misattribute to RFC 9111. immutable is from RFC 8246 (September 2017, Proposed Standard) and targets versioned-URL static assets — it is wrong for mutable API resources. stale-while-revalidate and stale-if-error come from RFC 5861 (May 2010), whose status is Informational, not Standards Track. That doesn't mean avoid them — browsers and CDNs implement both widely — but no layer is obligated to honor them, so they are an optimization, never a guarantee. For APIs, stale-if-error earns its keep: serving slightly stale data during an origin 5xx usually beats a hard error.
Vary is correct in theory and unreliable in deployment
RFC 9111 §4.1 defines the secondary cache key: a cache MUST NOT reuse a stored response without revalidation unless every request header field nominated by Vary matches the request that produced the stored response. Matching permits whitespace adjustment, merging repeated field lines, and normalization known to preserve semantics (case folding, reordering where order is insignificant). Two hard rules: a field absent from one request can only match a request where it is also absent, and a stored response whose Vary contains * always fails to match.
In practice, for APIs:
Vary: Accept-Encoding— mandatory, and effectively every CDN special-cases it.Vary: Accept— only if you truly negotiate representations.Vary: AuthorizationorVary: Cookie— looks safe, is useless. You get one cache entry per token, a hit rate of zero, and unbounded memory growth. Either mark the responseprivate/no-store, or authenticate at the edge. Do not useVaryas a security boundary.Vary: Accept-Language— browser-sent values are long-tailed (en-US,en;q=0.9,fr;q=0.8and its combinatorial siblings). Without normalization this is functionally equivalent to not caching.
The real hazard is that CDNs have historically not implemented Vary fully. Cloudflare's documentation states plainly that by default its CDN constructs cache keys from a request's URL and a handful of specific headers; Vary only takes effect where you configure it — in Cache Rules, or via cf.vary for Workers subrequests — and Vary for images is a separate mechanism. On 2 July 2026, Cloudflare shipped Vary support in Cache Rules across all plans, with three actions — normalize (collapse semantically equivalent header values into one cached version), passthrough (use raw values to create distinct versions), and bypass (skip caching when the named header appears in a Vary response) — while Vary: * continues to bypass the cache as RFC 9110 requires.
The honest reading of that changelog: any API design that relied on Cloudflare honoring Vary before July 2026 was quietly broken. You may never have noticed, because the failure mode is "some users receive a response in someone else's language or encoding," which almost never surfaces in dashboards. Verify with debug headers on the real path before you ship. Do not assume.
The rule that generalizes across all vendors: if a response body depends on a request header and any shared cache sits in the path, you must either make that cache genuinely key on the header, or make the response non-storable by shared caches. There is no safe middle state.
Layered control with CDN-Cache-Control
RFC 9213 (June 2022, Standards Track, authored out of Akamai, Fastly and Cloudflare) defines the targeted-cache-control convention and the CDN-Cache-Control field specifically.
The detail most people miss: targeted fields are Dictionary Structured Fields (RFC 9651, September 2024, obsoleting RFC 8941), not Cache-Control syntax. They look identical for simple cases, but error handling differs, and the spec explicitly warns that using a Cache-Control parser rather than a Structured Fields parser introduces interoperability issues. Directives with no value map to Boolean true; quoted-string values map to String; token values map to Token, Integer or Decimal.
The behavioral rule: a cache maintains an ordered target list, and on receiving a response it MUST select the first field in target-list order with a valid, non-empty value, use that to determine caching policy, and MUST ignore Cache-Control and Expires in that response — unless no listed field yields a valid non-empty value. Targeted fields not on a cache's target list MUST NOT change behavior and MUST be passed through. An empty or unparseable targeted field MUST be ignored, falling back to the other mechanisms. Caches honoring a targeted field MUST implement at least max-age, must-revalidate, no-store, no-cache and private.
The useful shape for an API:
Cache-Control: private, no-store
CDN-Cache-Control: max-age=60, stale-if-error=86400
Nothing lands in the browser; the CDN holds it for a minute. That is the right pattern for endpoints that are public but expensive to compute. But heed §2.3: the CDN now has a longer freshness lifetime than anyone downstream, so responses it serves can appear stale — possibly immediately stale — to other caches, hurting overall efficiency. Either accept that, or rewrite Cache-Control on egress at the edge.
Bulk invalidation finally has a standard
"An order changed, so drop every cached list page for that user" has for years been a vendor-specific problem, most commonly solved with Fastly's Surrogate-Key plus a purge API. RFC 9875, published October 2025, Standards Track, authored by Mark Nottingham, standardizes the shape. Two fields, both Lists of Strings:
HTTP/1.1 200 OK
Cache-Control: max-age=3600
Cache-Groups: "user-42-orders", "orders"
HTTP/1.1 200 OK
Cache-Group-Invalidation: "user-42-orders"
Read the constraints before designing around it:
- Two stored responses share a group only if their
Cache-Groupslists contain the same string compared character by character, case sensitive, and they share the same URI origin. Cross-origin grouping does not exist. Cache-Group-InvalidationMUST be ignored on responses to safe methods such as GET. It is meaningful only on POST/PUT/DELETE responses.- Invalidation is a MAY, not a MUST — a conforming cache may ignore it entirely. RFC 9213-style targeted fields could strengthen this to a requirement, but that needs a separate specification.
- It does not cascade: a grouped invalidation never triggers further grouped invalidations.
- Implementations MUST support at least 32 groups per field with at least 32 characters per member. Do not design a scheme with hundreds of groups.
- The spec is explicit that this operates within a single cache and does not address synchronizing state between caches.
The pragmatic position today: emit Cache-Groups as the forward-compatible path — it costs tens of bytes — but do not stake correctness on it. When invalidation must actually happen, call the vendor's purge API.
Debug with Cache-Status before you theorize
RFC 9211 (June 2022, Standards Track) defines Cache-Status. The parameter that matters most for API debugging is fwd, which names the reason the request was forwarded: bypass, method, uri-miss, vary-miss, miss, request, stale, partial. Seeing vary-miss ends the guessing — your secondary key didn't line up. collapsed tells you whether the request was merged with others, ttl gives remaining freshness in seconds, stored says whether the response was kept, and key conveys a representation of the cache key used.
Pair that with the Age rule: RFC 9111 §4 requires a cache that satisfies a request from a stored response without validation to generate an Age header equal to the stored response's current age. So the presence of Age proves the response was not generated or validated by the origin for this request. The converse does not hold — the spec is explicit that a missing Age does not imply the origin was contacted.
Defaults worth copying
| Endpoint | Cache-Control | Notes |
|---|---|---|
Per-user data (/me, order detail) |
private, no-store |
Strong ETag + If-Match on writes |
| Authenticated but cross-user shareable | private, max-age=0, must-revalidate |
Use sparingly; prefer edge auth for shared caches |
| Public, slow-changing | public, max-age=60, stale-if-error=86400 |
Pair with CDN-Cache-Control for layering |
| Public but expensive to compute | private, no-store + CDN-Cache-Control: max-age=300 |
Browser stores nothing; CDN absorbs the load |
| Write endpoints (PUT/PATCH/DELETE) | no-store |
428 when If-Match is missing, 412 on conflict |
Every row assumes Vary: Accept-Encoding and a strong ETag derived from a version, not a body hash.
The part worth remembering
The only genuinely hard idea here is that an ETag serves two unrelated purposes — saving bytes and preventing lost updates — and those two purposes use different comparison functions, different request headers, and different failure status codes. Internalize that split and the rest is table lookup. On the deployment side there is exactly one rule that has earned its place: any correctness that depends on Vary must be verified on the real path before it ships.