api performance · http caching · etag · cache-control · conditional requests · rest api · api design
HTTP Caching for Small Public APIs: ETags, Conditional Requests, and Cache-Control Without the Foot-Guns
A practical, opinionated guide for indie developers on implementing ETags, If-None-Match, and Cache-Control headers in small public REST APIs, with trade-offs and pitfalls.
Published:
What you actually need to cache in a small public API
If you maintain a small public REST API and your origin keeps doing the same work over and over for the same clients, you do not need a new database or a queue. You need three HTTP mechanisms done correctly: a sensible Cache-Control policy, an ETag per resource, and If-None-Match (plus If-Match for writes). This article walks through how those pieces fit together, where each one earns its keep, and the specific failure modes I would avoid shipping.
I will keep this grounded. No invented benchmarks, no promise that caching will cut your bill in half. The upside is real, but it is bounded by your traffic shape, and the downside of getting it wrong is serving stale data or breaking writes.
The three layers, in plain terms
Think of HTTP caching as three concentric layers. Pick the one that matches the risk profile of each endpoint; do not apply one rule to your entire API.
- Freshness window —
Cache-Control: max-age=N. The client is told the response is good for N seconds and should not revalidate during that window. - Validation —
ETagplusIf-None-Match. After the freshness window, the client asks the server whether the resource has changed. If not, the server replies304 Not Modifiedwith no body. - Write safety —
If-Matchwith the ETag the client received on its last read. The server only applies aPUTorPATCHif the client’s ETag still matches the current one. This prevents lost updates when two clients edit the same record.
These layers compose. You can set max-age and still validate after the window, and you can require If-Match on every write without ever sending a max-age on the response.
Setting Cache-Control correctly for an API
Cache-Control is the most consequential header you send, and it is also the most often misconfigured. The directives you will actually use, drawn from the standard set documented on MDN, are public, private, no-store, no-cache, max-age, s-maxage, must-revalidate, and stale-while-revalidate.
A small set of rules I follow:
no-storefor anything personalized. If a response varies by the requester (their user id, their auth token, their locale), mark itno-store. The headerprivateonly restricts who may cache; it does not say the response must be ignored.no-storeis the only directive that reliably tells every intermediary not to keep a copy.public, max-age=Nfor shared, slowly changing resources. A public list of product categories, an exchange rate table, a config object read by many clients. Pick N from how often the data actually changes, not from a desire to be fast. A 5-minute window on something that changes weekly is a small lie that nobody notices; a 1-hour window on something that changes every minute is a support ticket.no-cachewhen you want validation but not freshness. This is the underrated one.no-cachedoes not mean “do not cache.” It means “do not reuse without revalidating.” Pair it with anETagand clients will still get the speedup of a 304 once the resource exists in their cache. This is the right default for any resource that is read often and changes occasionally but where the cost of a stale read is non-zero.s-maxageto split the policy between browsers and shared caches. If you put a CDN in front,s-maxageoverridesmax-agefor shared caches and lets you give the edge a longer window than individual clients.must-revalidateonce you commit to a freshness window. Without it, a cache that cannot reach the origin is allowed to serve stale data indefinitely. With it, the cache must drop the entry when it expires.
A common pattern for read-only public resources:
Cache-Control: public, max-age=60, must-revalidate
ETag: "a1f0c2"
A common pattern for per-user resources:
Cache-Control: no-store
A common pattern for read-but-validate resources that change unpredictably:
Cache-Control: no-cache
ETag: "a1f0c2"
Generating ETags you can stand behind
An ETag is just a string the server hands the client as a “fingerprint” for the current representation. The two flavors the spec distinguishes are strong and weak ETags.
- A strong ETag (
ETag: "a1f0c2") means the bytes are byte-for-byte identical. Two responses with the same strong ETag are interchangeable. - A weak ETag (
ETag: W/"a1f0c2") means the representations are semantically equivalent but may differ in unimportant ways, like whitespace or the order of fields.
For JSON APIs, a weak ETag is almost always the right choice. Your response formatting is unlikely to be byte-stable across deployments, and a content hash over the canonicalized JSON is a perfectly good weak validator.
Common ways to generate the value:
- A hash of the response body. Simple and correct, but you must hash the canonical form, not whatever your serializer happened to emit that day. If you change your serializer and the bytes change, the ETag changes, and every cache misses. That is annoying but not unsafe.
- A version number you bump on write. Bump a counter in the database row every time it updates. The ETag is the counter. This is cheap and stable, but only if every code path that mutates the row goes through the same update path.
- A
Last-Modified-derived value. If you already store a timestamp, you can use it. It is weaker than a hash and has second-resolution ambiguity, but it is good enough for many public APIs.
What I would not do: derive the ETag from a hash of the entire row including fields the client cannot see, and then return only a subset of those fields. The ETag and the representation must describe the same bytes, or the client will be told that two different responses are equivalent.
Conditional GETs with If-None-Match
The read-side flow is the one you will implement first, because it is the cheapest win.
- Client sends
GET /widgets/42. No conditional headers. - Server responds
200 OKwith the body and anETag: "a1f0c2". - Client stores both. Some time later, it sends
GET /widgets/42withIf-None-Match: "a1f0c2". - Server compares the incoming ETag with the current one. If they match, it returns
304 Not Modifiedwith no body and the sameETag. If they do not, it returns200 OKwith the new body and the newETag.
The bandwidth savings on a 304 are real: there is no body, just headers. The latency savings depend on how much of your origin work you can skip. At minimum, a 304 lets you skip serializing the response and shipping it over the wire. A more aggressive implementation can short-circuit before hitting the database at all by storing ETags in memory keyed by resource id.
Frameworks help. Flask, Express, and FastAPI all have first-class or community-supported ETag handling. Express, for example, can auto-generate a weak ETag from the response body. For a small API, I would lean on that, then override it where the default is wrong.
There is a related conditional header, If-Modified-Since, which takes a timestamp instead of an ETag. It is older, coarser, and still useful when you genuinely do not want to compute a hash. If you ship both ETag and Last-Modified on the same response, clients will prefer If-None-Match and that is fine; the two are not in conflict.
Conditional writes with If-Match
The write-side flow is the one you should not skip, because it is the one that prevents data loss.
- Client reads
/orders/77and receivesETag: "b9c1". - Client sends
PATCH /orders/77withIf-Match: "b9c1"and the partial body. - Server compares the incoming ETag with the current one.
- Match: apply the patch, return
200 OKwith the new representation and a new ETag. - No match: return
412 Precondition Failedwith no body changes. The client is now responsible for re-reading, merging, and retrying.
- Match: apply the patch, return
This is the standard pattern for optimistic concurrency control. The server does not need to hold any locks; it just refuses the write when the client’s view of the world is stale. If-Match is the right header for PUT and PATCH. If-None-Match: * is a useful variant for PUT on a resource that must not yet exist, e.g., creating a record with a client-chosen id.
If you implement only one piece of this article, implement If-Match on writes. It costs almost nothing and it is the difference between an API that loses edits and an API that tells the client “someone else changed this, refresh and try again.”
Deciding when client-side caching is worth it
A honest framework: cache the response when the cost of serving it fresh is greater than the cost of occasionally being wrong.
- Cache freely when the resource is shared across users, changes infrequently, and the worst case of a stale read is a UI that updates a second later. Examples: public category lists, feature flags, exchange rates, configuration blobs.
- Cache with validation, not freshness when the resource is shared but changes unpredictably. The 304 path is cheap and safe. Examples: a user’s order list, a public article.
- Do not cache at all when the response is per-user, per-request, or carries information that the requester is not authorized to see other times. Examples: anything behind a session, anything derived from a request body, anything containing a one-time token.
- Be careful with
Varyif you are behind a CDN.Vary: Authorizationis the only safe way to keep per-user responses separated, and many CDNs treat it as a giant foot-gun. The simpler rule is: if a response is per-user, sendCache-Control: no-storeand do not try to be clever.
A note on stale-while-revalidate. It tells the cache that it may serve the stale copy while it fetches a fresh one in the background. For a small public API, it is a cheap way to make the first request after expiry feel instant. The trade-off is that the second request after expiry may still see the old value. For most public reads, that is acceptable. For anything where freshness is the product, it is not.
Concrete implementation steps
A short list to work through, in order, when adding caching to an existing small API.
- Inventory your endpoints. Mark each one as public-shared, public-validate, or per-user.
- For per-user endpoints, set
Cache-Control: no-storeand move on. This is the easiest win because it is the one that prevents leaks through shared caches. - For public-shared endpoints, pick a
max-agefrom how often the underlying data actually changes, and addmust-revalidateso a broken origin does not silently extend the window. - For public-validate endpoints, set
Cache-Control: no-cacheand generate a weak ETag for each representation. - On every read, include both
ETagandLast-Modified. They are cheap to add and they let clients pick. - Handle
If-None-MatchandIf-Modified-Sinceon the read path. Return304with the same validators and no body on a match. - On every write (
PUT,PATCH,DELETE), requireIf-Matchagainst the ETag the client last saw. Return412 Precondition Failedon a mismatch. - Log 304s and 412s separately from 200s. The ratio tells you whether the cache layer is doing useful work or just adding complexity.
- Write tests for the unhappy paths: missing ETag, stale ETag, mismatched weak/strong validators,
If-Matchon a resource that does not exist. These are the bugs that surface in production and never in development.
FAQ
Do I need both ETag and Last-Modified? No, but sending both is cheap and lets clients use whichever they prefer. If you have to pick, pick ETag; it is unambiguous.
Should ETag be a hash of the full row or just the response body? The response body, in its canonical form. If you hash the row and the response includes a derived field, the two can drift.
Is no-cache the same as no-store? No. no-store forbids storing the response at all. no-cache permits storing but requires revalidation on every use. They are very different directives and they are frequently confused.
What does the W/ prefix on an ETag mean? It marks a weak validator. Two responses with the same weak ETag are semantically equivalent but not necessarily byte-identical. For JSON APIs this is almost always what you want.
Can I use If-Match on POST? Generally no, because a POST is creating a resource the client has not seen yet. The If-None-Match: * form is the relevant one for “only create if this does not exist yet.”
What is the actual saving from a 304? No body, no serialization cost, and the client reuses the cached representation. The win is bandwidth on large responses and a small amount of CPU on the origin. The number depends on your response size and your backend; I would not promise a specific percentage without measuring your own traffic.
Closing thought
Caching for a small public API is not a performance project. It is a design project. Pick the right Cache-Control policy per endpoint, hand out weak ETags, validate reads with If-None-Match, and protect writes with If-Match. The four habits together are enough for most public APIs and they cost less to maintain than the alternative, which is a Redis layer and a talk at a meetup about cache invalidation.
Sources
- https://zuplo.com/learning-center/optimizing-rest-apis-with-conditional-requests-and-etags
- https://requestly.com/blog/etag-header-api
- https://www.speakeasy.com/api-design/caching
- https://schweizerischebundesbahnen.github.io/api-principles/restful/best-practices
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- https://www.rfc-editor.org/info/rfc9111
- https://www.apyflux.com/blogs/api-development/how-to-enable-partial-updates-apis-with-patch-etags
