ETag and Conditional Requests for JSON APIs: 304 Not Modified, Optimistic Concurrency

Last updated:

ETag is the HTTP response header that turns a JSON endpoint into a cache-validatable resource. The server attaches an opaque identifier — usually a hash of the body or a row version — and the client echoes it back on the next request in If-None-Match. If the resource has not changed, the server returns 304 Not Modified with an empty body, saving the full payload round-trip. The same machinery, with If-Match on writes, gives you optimistic concurrency control: a 412 Precondition Failed response when two clients try to update the same resource from the same starting version. RFC 9110 specifies the full semantics. This guide covers strong vs weak tags (the W/ prefix), the three generation strategies (hash, version, last-modified), framework code for Express, FastAPI, and Spring Boot, CDN interactions, and the pitfalls that cause endpoints to silently return 200 instead of the expected 304.

Debugging an ETag implementation that keeps returning 200 instead of 304? Paste your JSON response into Jsonic's JSON Validator to confirm the body is canonical and the bytes match between requests — a single reordered key changes the hash.

Validate your JSON response

How ETag + If-None-Match save bandwidth (304 round-trips)

A JSON endpoint that returns 50 KB of data on every poll wastes the full payload when the underlying resource has not changed. ETag + If-None-Match reduces the unchanged case to a near-empty round-trip: request headers go out, the server compares the supplied ETag against the current one, and returns 304 with no body. The wire cost drops from 50 KB to roughly the size of the response headers (a few hundred bytes).

The first request looks normal:

GET /api/orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v7-a3f9d2"
Cache-Control: private, max-age=0, must-revalidate

{"id": 42, "status": "shipped", "items": [...]}

The client stores the ETag alongside its cached copy. On the next poll, it sends the ETag back:

GET /api/orders/42 HTTP/1.1
Host: api.example.com
If-None-Match: "v7-a3f9d2"

HTTP/1.1 304 Not Modified
ETag: "v7-a3f9d2"
Cache-Control: private, max-age=0, must-revalidate

No body, no Content-Length issue, no JSON parse on the client. For dashboards that poll every few seconds, mobile apps that refresh on resume, and background sync jobs, the bandwidth difference between always-200 and mostly-304 is the difference between a usable feature and a battery-draining one. Combine ETags with proper Cache-Control headers and your JSON polling endpoint becomes nearly free when nothing changed.

Strong vs weak ETags (W/ prefix)

RFC 9110 defines two flavors of ETag distinguished by the W/ prefix. Strong ETags (no prefix) guarantee that two responses with the same ETag are bytewise identical. Weak ETags (prefix W/) guarantee only that the responses are semantically equivalent — the parsed meaning is the same, but the bytes may differ.

ETag: "abc123"            # strong — same bytes, every time
ETag: W/"abc123"          # weak — same meaning, possibly different bytes

The practical difference shows up when the response can have multiple byte representations of the same content:

  • Content encoding — the gzipped and identity versions of the same JSON have the same meaning but different bytes. A weak ETag covers both; a strong ETag must differ.
  • Volatile fields — if the response includes serverTime or a request ID, the bytes change on every call but the resource has not. Use a weak ETag based on the underlying row.
  • Pretty-printing — a minified and indented version of the same JSON have the same meaning, different bytes. Either pin a canonical form, or use weak ETags.

When does the distinction matter? Range requests (the Range header) require strong validators — partial-content responses are only safe to stitch together when the bytes are guaranteed identical. Most JSON APIs never serve ranges, so weak ETags are fine and often preferable. Express defaults to weak; flip to strong with app.set('etag', 'strong') only when you need byte-exact behavior.

Comparison rules: strong comparison requires both ETags to be strong and identical; weak comparison ignores the W/ prefix. If-None-Match uses weak comparison by default; If-Match uses strong comparison, which is what you want for optimistic concurrency.

Generating ETags: hash-based, version-based, last-modified-based

Three strategies cover almost every JSON endpoint. Pick based on whether you can modify the schema, how often the resource changes, and whether you need optimistic concurrency.

1. Hash-based. Compute SHA-256 of the serialized response body and truncate to 16 bytes (32 hex characters). Works for any data without schema changes; produces a strong ETag because identical bytes always hash the same way. CPU cost scales with payload size — fine for small JSON, worth measuring on endpoints that return megabytes.

import crypto from 'node:crypto'

function etagFromBody(body: string): string {
  const hash = crypto.createHash('sha256').update(body).digest('hex')
  return `"${hash.slice(0, 32)}"`  // 32 hex chars = 128 bits
}

2. Version-based. Store a monotonically increasing version integer (or updated_at with millisecond precision) on the underlying row. Use the version as the ETag. This is the cheapest option — no hashing on read — and it composes naturally with optimistic concurrency, since the same value the client used to fetch is the value you compare against on write.

// row = { id: 42, version: 7, ...fields }
const etag = `"v${row.version}"`  // "v7"

3. Last-modified-based. Use the row's updated_at timestamp as a weak ETag. Cheap, pairs with the Last-Modified header, but must be weak — two updates within the same second produce the same value, so strong comparison would silently miss the second change.

const etag = `W/"${row.updatedAt.getTime()}"`  // W/"1716480000000"

For read-mostly endpoints with no schema changes available, default to hash-based. For mutable resources where you already need optimistic concurrency, prefer version-based — one column doubles as the ETag and the lock. Pair this with HTTP caching patterns for the full stack.

Optimistic concurrency control with If-Match (412 Precondition Failed)

Conditional requests on writes turn ETag into a lightweight optimistic lock. The flow is read-then-conditional-write: the client GETs the resource (gets ETag "v7"), edits locally, then PUTs back with If-Match: "v7". The server accepts the write only if the current ETag still matches.

# 1. Read
$ curl -i https://api.example.com/orders/42
HTTP/1.1 200 OK
ETag: "v7"
Content-Type: application/json

{"id": 42, "status": "pending", "total": 99}

# 2. Modify and write with If-Match
$ curl -i -X PUT https://api.example.com/orders/42 \
    -H 'Content-Type: application/json' \
    -H 'If-Match: "v7"' \
    -d '{"id": 42, "status": "shipped", "total": 99}'

# 3a. Success: nobody else wrote
HTTP/1.1 200 OK
ETag: "v8"

# 3b. Conflict: someone else wrote first
HTTP/1.1 412 Precondition Failed
ETag: "v8"

On 412, the client must re-GET, decide how to reconcile its pending edit with the new server state (merge, rebase, prompt the user), and retry with the new ETag. The server does no state change on 412 — that is the whole point.

RFC 6585 also defines 428 Precondition Required: return this when an unconditional PUT arrives without any If-Match header, forcing clients to opt into optimistic concurrency rather than blindly overwriting. This is defensive — it prevents lost updates from clients that forgot to send the header.

Client-side retry on 412 is a standard pattern:

async function updateOrder(id: string, mutate: (o: Order) => Order) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(`/api/orders/${id}`)
    const etag = res.headers.get('ETag')!
    const order = await res.json()
    const next = mutate(order)

    const put = await fetch(`/api/orders/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', 'If-Match': etag },
      body: JSON.stringify(next),
    })

    if (put.status !== 412) return put  // success or non-conflict error
    // 412: retry with fresh ETag
  }
  throw new Error('Too many conflicts')
}

Combine with API idempotency keys when retries need to be safe even when the network drops between request and response.

Express, FastAPI, Spring Boot implementations

Express. The built-in etag setting handles cache-validation ETags automatically for any response — by default weak, with strong available via app.set('etag', 'strong'). Express compares the incoming If-None-Match against the outgoing ETag and rewrites a 200 into a 304 before sending. For full control (custom hashing, version-based tags, or If-Match handling), middleware is the better fit:

import express from 'express'
import crypto from 'node:crypto'

const app = express()
app.set('etag', 'strong')  // or 'weak' (default), or false to disable

// Custom middleware for version-based ETag + If-Match
app.put('/orders/:id', express.json(), async (req, res) => {
  const order = await db.orders.find(req.params.id)
  if (!order) return res.sendStatus(404)

  const currentEtag = `"v${order.version}"`
  const ifMatch = req.header('if-match')

  if (!ifMatch) return res.status(428).json({ error: 'If-Match required' })
  if (ifMatch !== currentEtag) {
    return res.status(412).set('ETag', currentEtag).end()
  }

  const updated = await db.orders.update(req.params.id, req.body)
  res.set('ETag', `"v${updated.version}"`).json(updated)
})

FastAPI. No built-in ETag handling — use a dependency for reads and explicit checks for writes. Hash the response in a small helper:

from fastapi import FastAPI, Request, Response, HTTPException, Header
from hashlib import sha256
import json

app = FastAPI()

def etag_for(payload: dict) -> str:
    body = json.dumps(payload, sort_keys=True, separators=(',', ':'))
    return f'"{sha256(body.encode()).hexdigest()[:32]}"'

@app.get('/orders/{order_id}')
async def get_order(order_id: int, request: Request, response: Response):
    order = await db.find_order(order_id)
    etag = etag_for(order)
    if request.headers.get('if-none-match') == etag:
        return Response(status_code=304, headers={'ETag': etag})
    response.headers['ETag'] = etag
    return order

@app.put('/orders/{order_id}')
async def update_order(
    order_id: int,
    payload: dict,
    if_match: str | None = Header(default=None),
):
    order = await db.find_order(order_id)
    current = f'"v{order["version"]}"'
    if if_match is None:
        raise HTTPException(status_code=428, detail='If-Match required')
    if if_match != current:
        raise HTTPException(status_code=412, detail='Version mismatch')
    updated = await db.update_order(order_id, payload)
    return Response(
        content=json.dumps(updated),
        media_type='application/json',
        headers={'ETag': f'"v{updated["version"]}"'},
    )

Spring Boot. Use the built-in ShallowEtagHeaderFilter for hash-based ETags on read endpoints, or WebRequest.checkNotModified() for explicit control. For optimistic concurrency on writes, JPA's @Version annotation pairs naturally with an If-Match check in the controller:

@RestController
public class OrderController {

  @GetMapping("/orders/{id}")
  public ResponseEntity<Order> get(@PathVariable Long id, WebRequest request) {
    Order order = repo.findById(id).orElseThrow();
    String etag = "\"v" + order.getVersion() + "\"";
    if (request.checkNotModified(etag)) return null;  // sends 304
    return ResponseEntity.ok().eTag(etag).body(order);
  }

  @PutMapping("/orders/{id}")
  public ResponseEntity<Order> put(
      @PathVariable Long id,
      @RequestBody Order body,
      @RequestHeader(value = "If-Match", required = false) String ifMatch) {

    if (ifMatch == null)
      return ResponseEntity.status(428).build();

    Order current = repo.findById(id).orElseThrow();
    String currentEtag = "\"v" + current.getVersion() + "\"";
    if (!ifMatch.equals(currentEtag))
      return ResponseEntity.status(412).eTag(currentEtag).build();

    body.setId(id);
    Order saved = repo.save(body);  // throws OptimisticLockException on race
    return ResponseEntity.ok().eTag("\"v" + saved.getVersion() + "\"").body(saved);
  }
}

Caches and proxies: how ETags interact with CDN and Vary

CDNs use ETag for revalidation. When a cached entry expires (per the max-age or s-maxage directive), the CDN does not just evict — it sends an If-None-Match request to the origin with the stored ETag. If the origin returns 304, the CDN refreshes the freshness window and serves the existing cached body. If the origin returns 200, the CDN replaces the cached body. This collapses the common case (resource unchanged) to a small origin round-trip.

Cache-Control: public, s-maxage=60, stale-while-revalidate=300
ETag: "v7-a3f9d2"

The pair above tells the CDN: cache for 60 seconds, serve stale for up to 5 minutes while revalidating in the background, and use the ETag to skip body re-downloads on revalidation. For most JSON APIs this combination keeps origin load low without serving very stale content to users.

The Vary header is where most CDN-ETag setups go wrong. Vary tells the cache that responses depend on the listed request headers, so each unique (URL, Vary-tuple) is a separate cache key. Vary: Accept, Authorization on a JSON endpoint means every authenticated user gets their own cache entry — which usually defeats the cache entirely. Keep Vary minimal:

  • Vary: Accept-Encoding — almost always needed (gzip vs identity)
  • Vary: Accept-Language — only if the response actually differs by locale
  • Vary: Authorization — only for shared caches that serve different users different bodies (rare for JSON APIs; user-specific responses usually go private)

For per-user responses, set Cache-Control: private so the CDN skips the entry entirely and only the browser caches it. ETag still works fine in the browser cache. For tag-based bulk invalidation, pair ETags with surrogate keys (Fastly Surrogate-Key, Cloudflare Cache-Tag) — the ETag handles per-request validation; the surrogate key handles "purge everything tagged 'orders'" on a bulk update.

Common pitfalls: ETag drift, weak ETag false negatives, surrogate keys

ETag drift on the same content. The server regenerates the response with a non-deterministic field — serverTime, a request ID, a randomized analytics token, a re-sorted set — and the hash changes even though the resource has not. Result: every request returns 200, never 304. The fix is to either base the ETag on the underlying resource (row version) rather than the serialized body, or canonicalize the response (sorted keys, fixed timestamp precision, no per-request fields).

Weak ETag false negatives. A weak ETag compared with strong comparison silently fails to match — the implementation sees W/"v7" and "v7" as different. Most libraries default to weak comparison for If-None-Match, but custom middleware sometimes does string equality, which is a strong comparison. Always strip the W/ prefix on both sides before comparing for cache-validation purposes.

Reverse proxies stripping or rewriting ETags. Some proxies (older nginx configs, certain WAFs) strip ETag headers, replace them with their own, or add a suffix on compression. The first symptom is the same: 200 every time. Check the response at the origin with curl -I and at the edge — if they differ, the proxy is the culprit. nginx since 1.7.3 preserves ETags through gzip, but custom modules can break this.

Forgetting to send ETag on 304. A 304 response should echo the current ETag so the client knows its cached copy is still valid (with the same tag). Skipping the ETag on 304 is technically allowed but confuses some clients that rely on it for cache bookkeeping.

Surrogate key mismatch. When using both ETags and CDN surrogate keys, make sure the surrogate-key set fully covers the data sources that feed into the ETag. A purge by surrogate key should invalidate exactly the cached entries whose ETags can no longer match the origin. Mismatched scoping (ETag based on one row, surrogate key covering a different set of rows) produces stale cached entries that never refresh.

Content-Length on 304. Some implementations set Content-Length on a 304 response. RFC 9110 says the 304 response should not include a body, and some intermediaries get confused by a Content-Length without a body. Safest: omit Content-Length on 304.

ETag vs Last-Modified — when to use which

The two validators do the same job at different resolutions. Last-Modified is a timestamp at second granularity; ETag is an opaque identifier with no granularity constraint. RFC 9110 says clients should prefer ETag when both are present.

PropertyETagLast-Modified
ResolutionArbitrary (hash, version, anything)1 second (HTTP-date format)
Detects sub-second changesYesNo — silent miss
Detects content-equivalent rewritesYes (if hash-based)No (touch updates timestamp without content change)
Conditional read headerIf-None-MatchIf-Modified-Since
Conditional write headerIf-MatchIf-Unmodified-Since
Optimistic concurrency controlStrong fit (especially version-based)Weak — second resolution misses fast updates
Compute costHash (CPU) or version (cheap)Read updated_at (cheap)
Human-readable in logsNo (opaque)Yes (timestamp)

For JSON APIs, default to ETag-only. Add Last-Modified when you have callers (legacy clients, simple static-file consumers) that explicitly send If-Modified-Since but not If-None-Match. Sending both is harmless and well-defined: the client picks the validator it prefers, and a well-behaved client picks ETag.

The one place Last-Modified still wins is in human-readable cache debugging — a timestamp tells you when the resource changed, while an opaque hash does not. For that, log updated_at server-side but send only ETag over the wire. See also our JSON caching strategies guide for the full freshness model around these headers.

Key terms

ETag
An opaque, quoted string in an HTTP response that identifies a specific representation of a resource. Defined in RFC 9110 §8.8. Clients echo it back in If-None-Match (reads) or If-Match (writes).
Strong ETag
An ETag without a W/ prefix that guarantees byte-for-byte identical responses across requests. Required for HTTP range requests; useful when caches compare by exact content.
Weak ETag
An ETag prefixed with W/ that guarantees only semantic equivalence — same parsed meaning, possibly different bytes. Default in Express; good fit when the response includes encoding-dependent or volatile bytes that do not affect meaning.
304 Not Modified
HTTP status returned when an If-None-Match ETag matches the current resource. The response has no body — only headers — which is the bandwidth savings.
412 Precondition Failed
HTTP status returned when an If-Match ETag does not match the current resource. Signals optimistic-concurrency conflict; the server makes no state change and the client must re-read and retry.
428 Precondition Required
RFC 6585 status the server returns when a mutating request arrives without an If-Match header. Used to force clients to opt into optimistic concurrency rather than blindly overwriting.
Optimistic concurrency control
A locking strategy where clients read with a version, modify locally, and write back conditional on the version not having changed. Lightweight compared to pessimistic locks; rejects writes on conflict and pushes resolution to the client.

Frequently asked questions

What is the difference between If-None-Match and If-Match?

If-None-Match is for cache validation on reads: the client sends the ETag it last saw, and the server returns 304 Not Modified with an empty body if the resource has not changed, or 200 OK with the new body and a new ETag if it has. The goal is to save bandwidth on unchanged JSON responses. If-Match is for optimistic concurrency control on writes: the client sends the ETag of the version it intends to update, and the server returns 412 Precondition Failed if that ETag does not match the current server-side value, meaning someone else updated the resource between the read and the write. The client must then re-read, merge or rebase its changes, and retry. Both headers accept a comma-separated list of ETags, plus the special wildcard * which matches any existing representation — useful as If-Match: * to ensure the resource exists before updating, or as If-None-Match: * on POST to prevent overwriting an existing resource.

What's a weak ETag (W/) and when do I use one?

A weak ETag is prefixed with W/ — for example W/"abc123" — and signals that two responses with the same weak ETag are semantically equivalent but may not be byte-for-byte identical. RFC 9110 defines strong ETags as guaranteeing bytewise identity: same ETag means the response bytes are exactly the same. Weak ETags only guarantee that the meaningful content is the same. Use weak ETags when your representation can be served with different byte representations of the same logical content — for example when gzip and identity encodings of the same JSON produce different byte streams but the same parsed value, or when a timestamp like serverTime is regenerated on every request but no other field changes. Use strong ETags when you need range requests (RFC 9110 requires strong validators for byte-range requests), or when the cache layer compares ETags for object identity rather than meaning. Express defaults to weak ETags; switch to strong with app.set("etag", "strong") when you need byte-exact comparisons.

How do I generate an ETag for a JSON response?

Three patterns cover almost every JSON API. (1) Hash-based: compute SHA-256 of the serialized response body and truncate to 16–32 hex characters. This is the most general approach — it works for any data, requires no schema changes, and produces a strong ETag because the same bytes always produce the same hash. The downside is the CPU cost on large payloads. (2) Version-based: store a monotonically increasing version column on the underlying row and use that value as the ETag (e.g. ETag: "v42"). Cheap to compute, works perfectly with optimistic concurrency control, but requires schema changes. (3) Last-modified-based: use the updated_at timestamp as the ETag (e.g. ETag: W/"1716480000"). Cheap and pairs with Last-Modified, but you must mark it weak because two updates within the same second produce the same tag. For most read-mostly JSON endpoints, hash-based is the safest default; for mutable resources, prefer the version column.

Why is my client always getting 200 instead of 304?

Five common causes. (1) The server is regenerating the ETag from a body that changes on every request — for example the JSON includes a serverTime, requestId, or now() field that differs each call. Move volatile fields out of the hashed content, or use a weak ETag based on the underlying resource version. (2) The proxy or framework is stripping the ETag — Express adds an ETag automatically, but some reverse proxies remove it. Check the response headers with curl -I and verify the ETag is present. (3) The client is not echoing the ETag in If-None-Match — log incoming requests and check the header is exactly the ETag value the server sent (with quotes and any W/ prefix preserved verbatim). (4) Vary headers mismatch — if the response varies by Accept-Encoding and the client sends a different encoding, the cached entry is a miss. (5) The framework comparison is strong-only and your ETag is weak (or vice versa). Make the comparison rule explicit on both sides.

How does ETag interact with CDN caching?

CDNs use ETag for revalidation: when the cached entry expires (per Cache-Control max-age), the CDN issues an If-None-Match request to the origin using the stored ETag. If the origin returns 304, the CDN keeps the existing cached body and resets its freshness window without re-downloading the payload. If the origin returns 200, the CDN replaces the cached body. This works best when Cache-Control includes both s-maxage (for the CDN) and stale-while-revalidate (so the CDN can serve stale content while it revalidates in the background). A common pitfall is the Vary header — the CDN treats each (URL, Vary-tuple) as a separate cache key, so Vary: Accept, Authorization can fragment the cache to the point that no two requests share an entry. Keep Vary minimal on JSON endpoints; usually Vary: Accept-Encoding is enough. For aggressive cache busting, pair ETag with surrogate keys (Fastly Surrogate-Key, Cloudflare Cache-Tag) for tag-based invalidation.

How do I use If-Match for optimistic locking on PUT?

The flow is read-then-write. (1) Client GETs the resource: GET /orders/42 → 200 OK with ETag: "v7" and a JSON body. (2) Client modifies the body locally. (3) Client PUTs back: PUT /orders/42 with If-Match: "v7" and the modified body. (4a) If no one else updated the row, the server compares "v7" against the current version, accepts the write, increments the version, and returns 200 OK with ETag: "v8". (4b) If another client wrote between the read and the write, the current version is now "v8", the If-Match check fails, and the server returns 412 Precondition Failed with no body change. The client must re-GET the resource, decide how to merge its pending edit against the new state, and retry with the new ETag. RFC 6585 also defines 428 Precondition Required, which you can return when an unconditional PUT arrives without If-Match — useful for forcing all clients to opt in to optimistic concurrency.

What is the maximum length of an ETag value?

RFC 9110 does not specify a maximum length for ETag values, but practical limits come from the HTTP header size cap that most servers and CDNs enforce — typically 8 KB to 16 KB for the entire header block. Within that, an ETag is usually 16–64 characters and rarely needs more. SHA-256 truncated to 16 bytes (32 hex characters) gives 128 bits of collision resistance, which is more than enough for cache-validation purposes. Some teams use the full 64-character SHA-256, but the extra bytes cost bandwidth on every response and every If-None-Match round-trip with no practical benefit. Version-based ETags are even shorter (often 4–8 characters). The format is also constrained: ETag values must be quoted strings ("..." or W/"..."), opaque to the client, and contain no control characters. Keep them ASCII; binary content goes through base64 or hex encoding first.

Should I use ETag or Last-Modified or both?

Use ETag for any resource that changes more often than once per second or whose update timestamps are unreliable — most JSON API endpoints fit this. Last-Modified is a second-resolution timestamp, so two writes in the same second produce the same value and the conditional check silently fails. ETag also handles content equivalence: if you regenerate a response that happens to have the same bytes, a hash-based ETag will match. Last-Modified is useful as a fallback for very simple resources (static files, daily reports) and for human-readable cache debugging. Sending both is harmless and gives clients a choice; RFC 9110 says clients should prefer ETag when both are present. The conditional headers pair accordingly: If-None-Match goes with ETag, If-Modified-Since goes with Last-Modified. For JSON APIs, default to ETag-only; add Last-Modified if you have callers that explicitly need it.

Further reading and primary sources