Editorial illustration: HTTP Caching for Small Public APIs: ETags, Conditional Requests, and Cache-Control Without the Foot-Guns

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.

  1. Freshness windowCache-Control: max-age=N. The client is told the response is good for N seconds and should not revalidate during that window.
  2. ValidationETag plus If-None-Match. After the freshness window, the client asks the server whether the resource has changed. If not, the server replies 304 Not Modified with no body.
  3. Write safetyIf-Match with the ETag the client received on its last read. The server only applies a PUT or PATCH if 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:

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.

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:

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

  1. Client sends GET /widgets/42. No conditional headers.
  2. Server responds 200 OK with the body and an ETag: "a1f0c2".
  3. Client stores both. Some time later, it sends GET /widgets/42 with If-None-Match: "a1f0c2".
  4. Server compares the incoming ETag with the current one. If they match, it returns 304 Not Modified with no body and the same ETag. If they do not, it returns 200 OK with the new body and the new ETag.

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.

  1. Client reads /orders/77 and receives ETag: "b9c1".
  2. Client sends PATCH /orders/77 with If-Match: "b9c1" and the partial body.
  3. Server compares the incoming ETag with the current one.
    • Match: apply the patch, return 200 OK with the new representation and a new ETag.
    • No match: return 412 Precondition Failed with no body changes. The client is now responsible for re-reading, merging, and retrying.

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.

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.

  1. Inventory your endpoints. Mark each one as public-shared, public-validate, or per-user.
  2. For per-user endpoints, set Cache-Control: no-store and move on. This is the easiest win because it is the one that prevents leaks through shared caches.
  3. For public-shared endpoints, pick a max-age from how often the underlying data actually changes, and add must-revalidate so a broken origin does not silently extend the window.
  4. For public-validate endpoints, set Cache-Control: no-cache and generate a weak ETag for each representation.
  5. On every read, include both ETag and Last-Modified. They are cheap to add and they let clients pick.
  6. Handle If-None-Match and If-Modified-Since on the read path. Return 304 with the same validators and no body on a match.
  7. On every write (PUT, PATCH, DELETE), require If-Match against the ETag the client last saw. Return 412 Precondition Failed on a mismatch.
  8. Log 304s and 412s separately from 200s. The ratio tells you whether the cache layer is doing useful work or just adding complexity.
  9. Write tests for the unhappy paths: missing ETag, stale ETag, mismatched weak/strong validators, If-Match on 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