ETags are a fingerprint of a resource's current state, and they quietly do two jobs at once: they let a client skip re-downloading data that hasn't changed (conditional GET, answered with 304 Not Modified), and they let a client refuse to overwrite a change it never saw (conditional write, answered with 412 Precondition Failed). One header, two contracts.

Quick Answer: Cache-Control sets caching policy; ETag adds a precise version fingerprint on top of it. Send If-None-Match on GET requests for cheap 304 responses, and If-Match on PUT/PATCH/DELETE requests to reject writes based on stale data with 412 Precondition Failed.

Most API teams treat caching and concurrency as unrelated concerns — one lives in the CDN config, the other in application code with row versions or timestamps. ETags collapse them into a single, standard HTTP mechanism. If you're specifying endpoints, that's a design decision worth making explicit, not an implementation detail you leave to whoever writes the controller.

Cache-Control: The First Layer of HTTP Caching Policy

Cache-Control is the header that tells a client or intermediary whether and for how long a response can be reused without asking the server again. It's the coarse-grained policy layer; ETags are the fine-grained validation layer that sits on top of it. You need both, because they answer different questions.

Cache-Control answers "can I skip the network entirely for a while?" A max-age=60 response can be served straight from a browser or CDN cache for 60 seconds with zero requests to your origin — no round trip at all. That's the cheapest possible caching outcome, but it only works for data that's genuinely safe to serve stale for that window.

The most common directives worth knowing cold:

DirectiveWhat it doesTypical use
max-age=NResponse is fresh for N seconds; no revalidation neededPublic catalog data, config endpoints
no-cacheCache it, but revalidate with the server before reuse (misleadingly named — it doesn't mean "don't cache")Frequently changing resources you still want cache metadata for
no-storeNever cache this response, anywhereAuth tokens, PII-bearing responses
privateOnly the end-user's own cache may store it, not shared/CDN cachesPer-user account data
must-revalidateOnce stale, never serve without checking origin, even under network failureFinancial balances, inventory counts

The gap Cache-Control alone leaves is precision. max-age=60 is a guess about how often data changes — too short and you barely save any load, too long and clients see stale data. ETags remove the guesswork: the server says, definitively, "here is exactly whether anything changed," on every request, for the cost of a lightweight round trip instead of a full payload.

That combination — coarse Cache-Control freshness plus ETag-based revalidation — is the pattern behind REST API design done well, and it's worth specifying both, not just one.

The IETF's HTTP caching specification, RFC 9111, also draws a distinction PMs frequently miss: private vs. the implicit shared-cacheability of a plain public response. A response cached by a shared CDN is visible to every requester hitting that edge node — fine for a product catalog, a correctness problem for anything scoped to one account. Get that wrong and a caching optimization becomes a data-leak incident, not just a stale-data annoyance.

ETags and Conditional GET: How 304 Not Modified Cuts Load

An ETag is an opaque token — usually a hash or version identifier — that represents the exact current state of a resource. A client that already has a copy sends it back via If-None-Match; if nothing changed, the server replies 304 Not Modified with no body at all, instead of re-sending the full payload.

Here's the round trip, concretely:

  1. Client requests GET /orders/482. Server responds 200 OK with the order payload and a header ETag: "a1b2c3d4".
  2. Client caches the payload alongside that ETag value.
  3. On the next request, client sends GET /orders/482 with If-None-Match: "a1b2c3d4".
  4. If the resource hasn't changed, server responds 304 Not Modified — no body, just headers. If it has changed, server responds 200 OK with the new payload and a new ETag.

The saving isn't just bandwidth. A 304 response typically skips serialization, and often skips the expensive part of the request entirely if the ETag can be computed from a cheap version column or updated_at timestamp rather than re-fetching and re-rendering the full resource. For high-read endpoints — a product catalog, a dashboard widget polled every few seconds, a public status page — that difference compounds fast across traffic volume.

This matters most for high-read, low-write resources: things many clients fetch repeatedly but few clients change. A well-modeled resource — see resource modeling around nouns, not verbs — makes ETag computation straightforward, because the resource has one clear canonical representation to fingerprint instead of several inconsistent ones assembled ad hoc per endpoint.

GitHub's REST API is a widely cited real-world case: its own developer documentation states that conditional requests answered with 304 Not Modified don't count against a caller's rate limit. That single design choice turns ETags from a nice-to-have into a direct lever on how often high-volume integrators get throttled — a concrete, publicly documented incentive to support conditional GET on any endpoint polled by external clients or webhooked integrations.

Conditional GET is entirely a REST-native idiom — it relies on cacheable, addressable resources and standard HTTP semantics. If you're choosing between REST, RPC, and GraphQL for a given surface, ETag-based caching is one of the concrete advantages REST retains over both alternatives, which generally require bespoke caching layers to get equivalent behavior.

The Lost-Update Race: Why Caching Alone Isn't Enough

Conditional GET protects reads. It does nothing for writes — and unprotected writes have a specific, well-documented failure mode called the lost update problem: two clients read the same resource, both edit it independently, and whichever writes second silently erases the first client's change with no error, no warning, and no merge.

Walk through the sequence exactly as it happens:

  1. Client A fetches a project record: status: "In Progress", owner: "Priya".
  2. Client B fetches the same record a moment later — same values.
  3. Client A edits owner to "Priya" → "Jordan" and saves.
  4. Client B, still holding its original copy, edits status to "Blocked" and saves — using a payload that still says owner: "Priya".
  5. Client B's write overwrites the whole record. Client A's ownership change is gone. Nobody sees an error. The system just quietly reverts a change that was already confirmed to the first user.

This is the exact failure that Martin Fowler's Optimistic Offline Lock pattern (from Patterns of Enterprise Application Architecture) was written to prevent, and it's precisely what the IETF's HTTP semantics specification, RFC 9110, formalized as a standard, conditional-request mechanism rather than something every team reinvents with ad hoc version columns or last-write-wins defaults.

The business cost isn't hypothetical. Any collaborative surface — shared documents, ticket queues, inventory counts, pricing rules edited by more than one team — accumulates these silent overwrites at a rate proportional to concurrent editors and write frequency. Nobody files a bug report for a change that vanished without an error; they just quietly redo work, or worse, don't notice and ship the wrong state.

For the person on the receiving end, that's a frustrating, trust-eroding moment worth mapping explicitly onto your customer journey and its emotion curve — "I made a change and it disappeared" is a sharp, memorable dip, not a minor annoyance.

Conditional Writes with If-Match: Optimistic Concurrency in Practice

Optimistic concurrency control assumes conflicts are rare, so it doesn't lock anything up front — it lets both clients proceed, then checks at write time whether the resource still matches what the client last saw. ETags make this a one-header addition instead of custom version-column logic.

The mechanism mirrors conditional GET, but on the write side:

  1. Client fetches the resource and receives ETag: "v7".
  2. Client sends PUT (or PATCH/DELETE) with header If-Match: "v7".
  3. If the current ETag still equals "v7", the server applies the write and returns a new ETag for the updated state.
  4. If the current ETag has changed — someone else wrote in between — the server rejects the write outright with 412 Precondition Failed, and applies nothing.

That 412 is the entire point: it converts a silent, invisible data loss into a loud, recoverable error the client can act on — refetch, show the user what changed, prompt a merge, or simply retry with fresh data. Handled well, a 412 is a UI moment ("this record changed — reload to see the latest"), not a stack trace a user never sees the meaning of.

One subtlety worth specifying explicitly: a 412 should never be treated by client retry logic the same way a 503 or timeout is. Blindly retrying the same If-Match value will just fail again forever, since the precondition is permanently false against the new state — the correct client behavior is always refetch, then retry with the new ETag, not a naive exponential backoff. Compare the two conditional-header pairs side by side, because they're easy to swap by accident:

HeaderDirectionUsed withSuccessFailure
If-None-MatchClient → serverGET (conditional read)304 Not Modified200 OK with fresh body
If-MatchClient → serverPUT/PATCH/DELETE (conditional write)Write proceeds, 200/204412 Precondition Failed
If-Modified-SinceClient → serverGET, timestamp-based fallback304 Not Modified200 OK with fresh body
If-Unmodified-SinceClient → serverWrite, timestamp-based fallbackWrite proceeds412 Precondition Failed

Note the asymmetry: If-None-Match on a GET means "give me the full response only if this doesn't match" (cheap read path), while If-Match on a write means "only proceed if this does match" (safety gate). Same header family, opposite polarity, and mixing them up is a common spec-writing mistake worth catching in review rather than in production.

Whether you require If-Match on every write or only recommend it is itself a product decision, not just an engineering one — it depends on the job the client is actually trying to do. A single-user settings toggle rarely needs it; a shared record edited by multiple people or systems almost always does.

Framing it through jobs-to-be-done helps here: the client isn't hiring your PUT endpoint just to "save data," it's hiring it to "save data without clobbering someone else's work" — and that second half of the job is exactly what If-Match delivers.

Strong vs. Weak ETags: Picking the Right Comparison Semantics

HTTP defines two ETag flavors, and the difference is about how strict "identical" needs to be, not just syntax. A strong ETag (ETag: "abc123") asserts byte-for-byte identical representations — same bytes, same encoding, everything. A weak ETag (ETag: W/"abc123") asserts only semantic equivalence — the resource means the same thing, even if formatting, whitespace, or compression differs.

The practical difference shows up in comparison rules:

  • Strong comparison (required for range requests and safest for If-Match) requires an exact byte match — used when correctness genuinely depends on the payload being identical.
  • Weak comparison allows two responses to be treated as equivalent even with trivial differences — useful for HTML pages, gzip-vs-uncompressed variants, or responses where only the meaningful content matters.

For conditional writes, prefer strong ETags: the entire safety guarantee of If-Match rests on the ETag reliably distinguishing "actually the same version" from "looks similar but isn't." A weak ETag that treats two logically-different states as equal defeats the purpose of optimistic concurrency. For conditional GET on largely static or compressible content, weak ETags are fine and often unavoidable, since proxies and compression layers can alter bytes without changing meaning.

RFC 9110 is explicit that If-Match must use strong comparison by default — which is easy to violate by accident, since several popular web frameworks generate weak ETags out of the box for convenience. If your team hasn't checked which flavor its framework emits, that's worth verifying before you lean on If-Match for anything correctness-sensitive; a weak ETag silently accepted where the spec calls for strong comparison is a gap that only shows up under real concurrent load, not in a quick manual test.

A quick way to remember it: strong ETags for correctness-critical writes, weak ETags are acceptable for read-side caching convenience.

Writing the Caching and Concurrency Contract Into Your API Spec

None of this works if it lives only in one engineer's memory of how a particular endpoint behaves. ETag support — which endpoints emit it, whether it's strong or weak, whether If-Match is required or optional on writes — is contract information, exactly like a status code or a request body schema, and belongs in the same place those do.

That's the gap most API specs leave open: a schema will meticulously document every field and status code, then say nothing about caching or concurrency headers at all, leaving client teams to discover the behavior by trial and error — or worse, to assume it doesn't exist and build their own version-check logic on top of yours. Specifying it explicitly is part of doing the PM's job of specifying an API contract properly, not an implementation footnote to leave for later.

This is exactly the gap Prodinja's API Designing tool is built to close: it lets you document ETag, If-None-Match, and If-Match headers directly on an endpoint definition, alongside the request and response shapes it already walks you through, and generates the matching curl/spec output — so caching behavior and concurrency behavior are both part of the documented contract a client team hands off against, instead of tribal knowledge that lives in one backend engineer's head.

Key Takeaways

  • Cache-Control and ETag solve different problems — coarse time-based freshness versus precise state-based validation — and a well-designed endpoint typically specifies both.
  • Conditional GET (If-None-Match → 304 Not Modified) cuts bandwidth and server work for high-read resources without ever risking stale data, since the server always confirms freshness.
  • The lost-update race is silent by default: two clients editing the same resource can have one write invisibly erase the other's change unless the API enforces a precondition.
  • Conditional writes (If-Match → 412 Precondition Failed) convert that silent data loss into a loud, recoverable error the client can respond to.
  • Strong ETags belong on correctness-critical writes; weak ETags are an acceptable convenience for read-side caching where only semantic equivalence matters.
  • ETag behavior is contract information, not an implementation detail — document it on the endpoint the same way you document status codes and schemas.

Frequently Asked Questions

What's the difference between ETag and Last-Modified headers?

ETag is an opaque token representing exact resource state, while Last-Modified is a timestamp with only second-level precision. ETags handle rapid successive changes and non-time-based versioning correctly; Last-Modified is a simpler fallback for servers that can't cheaply compute a fingerprint but can track a modification time.

Does every API endpoint need ETags?

No — reserve them for endpoints with meaningful read volume, shared/concurrent write access, or both. A private, rarely-changed, single-owner resource gains little from the added complexity; a shared record edited by multiple users or a heavily polled read endpoint benefits significantly from either or both mechanisms.

Can ETags be used with POST requests?

Not for the conditional-write pattern in the usual sense, since POST typically creates a new resource rather than updating an existing, versioned one. If-Match applies naturally to PUT, PATCH, and DELETE against an existing resource identified by a URL; some APIs use If-None-Match: * on POST/PUT specifically to enforce "create only if it doesn't already exist."

What HTTP status code should a failed conditional write return?

412 Precondition Failed is the standard response when an If-Match (or If-Unmodified-Since) condition isn't met — it signals the write was rejected specifically because the resource changed underneath the client, distinct from a 409 Conflict, which typically indicates a broader state conflict not tied to a precondition header.

Do GraphQL and RPC-style APIs support conditional requests the same way?

Not natively in the same way REST does, since conditional requests rely on cacheable, individually-addressable resource URLs that GraphQL's single-endpoint model and most RPC designs don't expose the same way. Teams choosing between architectural styles should weigh this as one concrete tradeoff, alongside the broader considerations in comparing REST, RPC, and GraphQL approaches.