Quick answer
An ETag identifies a selected representation. Return it on 200; a matching If-None-Match on GET produces 304 without content. If-Modified-Since is the less precise fallback and is ignored whenever If-None-Match is present.
For updates, require strongly compared If-Match. Reject stale writes with 412 Precondition Failed, and make comparison plus mutation atomic.
RFC 9111 obsoletes RFC 7234. RFC 9110 defines validator and precondition semantics.
Freshness and validation solve different problems
Freshness permits reuse without origin contact; validation checks whether stored content remains current. After max-age=60 expires, a validator can avoid a full transfer. no-cache permits storage but requires validation; an ETag defines neither privacy nor freshness.
Strong and weak validators
A strong tag such as "article-v7" supports ranges and lost-update protection. A weak validator has a case-sensitive prefix:
ETag: W/"article-v7"
Weak comparison ignores W/; strong comparison requires identical opaque values on two strong tags. Generate tags from deterministic bytes or a stable version, with distinct validators for representation variants.
ETag and If-None-Match
If-None-Match is a list-capable precondition:
If-None-Match: "old", W/"article-v7"
For GET and HEAD, weak comparison makes either form match current "article-v7". * matches any current representation. A match produces 304; otherwise processing continues.
Do not compare the whole field to one tag or call split(","): quoted opaque tags may contain commas. Use a compliant parser and test lists, weak tags, *, and "release,7".
Last-Modified and If-Modified-Since
Last-Modified is an HTTP date rounded to whole seconds. A later GET can send that value in If-Modified-Since; if the selected representation has not been modified since that time, the origin can return 304.
Dates have one-second precision and can be unreliable. RFC 9110 requires ignoring If-Modified-Since whenever If-None-Match is present, including when no tag matches.
The complete 200 to 304 flow
- The client requests without a validator.
- The origin returns
200, content, policy, ETag, and optionalLast-Modified. - When validation is required, the client sends
If-None-Match. - The origin selects the representation and compares validators.
- A weak match on
GETorHEADyields304; otherwise it sends the current200.
A 304 has no content or trailers. Retain applicable fields from the equivalent 200: Cache-Control, Content-Location, Date, ETag, Expires, and Vary.
If-Match and lost-update prevention
Suppose Alice and Bob both read "article-v7". Bob updates first and creates "article-v8". Alice must send:
If-Match: "article-v7"
If-Match uses strong comparison. Alice’s stale tag produces 412 without applying her body. A weak W/"article-v8" also fails; If-Match: * succeeds when a current representation exists.
Close the race with UPDATE ... WHERE id = ? AND version = ? or a transactional lock. 428 Precondition Required can make guarded updates mandatory.
Java Spring Boot ETag implementation
This controller quotes a version that changes with every visible change. checkNotModified handles tag lists, weak cache comparison, date precedence, and *.
@RestController
@RequestMapping("/api/articles")
final class ArticleController {
private static final CacheControl POLICY =
CacheControl.noCache().cachePublic();
private final ArticleService service;
private final ObjectMapper mapper;
ArticleController(ArticleService service, ObjectMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@GetMapping("/{id}")
ResponseEntity<byte[]> get(
@PathVariable long id,
WebRequest request,
HttpServletResponse response
) throws JsonProcessingException {
ArticleSnapshot article = service.require(id);
String etag = "\"article-v" + article.version() + "\"";
long modified = article.modifiedAt().toEpochMilli();
// Set metadata before evaluation so Spring retains it on 304 or 412.
response.setHeader(HttpHeaders.CACHE_CONTROL, POLICY.getHeaderValue());
boolean terminal = article.lastModifiedReliable()
? request.checkNotModified(etag, modified)
: request.checkNotModified(etag);
if (terminal) {
// checkNotModified selected and prepared 304 or 412.
return null;
}
byte[] body = mapper.writeValueAsBytes(article.view());
ResponseEntity.BodyBuilder ok = ResponseEntity.ok()
.cacheControl(POLICY)
.eTag(etag)
.contentType(MediaType.APPLICATION_JSON);
if (article.lastModifiedReliable()) {
ok.lastModified(modified);
}
return ok.body(body);
}
}
A version avoids hashing, but every visible change must advance it. Evaluating preconditions before serialization avoids rendering unused bytes. ShallowEtagHeaderFilter saves transfer bytes, not rendering work.
checkNotModified evaluates more than cache validation. A stale If-Match or failed If-Unmodified-Since makes it set 412; a matching cache validator on GET makes it set 304. Returning null preserves that prepared response instead of replacing every true result with 304.
lastModifiedReliable() is true only when its second exceeds every date exposed for an older version. Otherwise the ETag-only path omits Last-Modified. This persistent high-water rule covers same-second writes, rollback, and catch-up; reliable snapshots retain date preconditions.
For a guarded update, check the request precondition and close the race in the service:
@PutMapping("/{id}")
ResponseEntity<?> put(
@PathVariable long id,
@RequestBody UpdateArticle command,
WebRequest request
) {
ArticleSnapshot current = service.require(id);
String currentTag = "\"article-v" + current.version() + "\"";
if (request.getHeader(HttpHeaders.IF_MATCH) == null) {
return ResponseEntity.status(428).build();
}
if (request.checkNotModified(currentTag)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
.eTag(currentTag)
.build();
}
return service.updateIfVersion(id, current.version(), command)
.<ResponseEntity<?>>map(saved -> ResponseEntity.ok()
.eTag("\"article-v" + saved.version() + "\"")
.body(saved.view()))
.orElseGet(() -> ResponseEntity
.status(HttpStatus.PRECONDITION_FAILED)
.eTag(service.currentEtag(id))
.build());
}
updateIfVersion performs one conditional update; zero affected rows triggers the second 412.
Node.js Express ETag implementation
Express 4’s req.fresh is convenient, but its dependency tokenizes on every comma and cannot parse a comma inside a quoted tag. This parser handles weak tags, lists, and *; the route rejects malformed values as 400.
function parseTagList(value) {
const text = value.trim();
if (text === "*") return { wildcard: true, tags: [] };
const tags = [];
let emptyElements = 0;
let i = 0;
while (i < text.length) {
while (text[i] === " " || text[i] === "\t") i++;
if (text[i] === ",") {
if (++emptyElements > 16) return null;
i++;
continue;
}
if (i === text.length) break;
let weak = false;
if (text.startsWith("W/", i)) {
weak = true;
i += 2;
}
if (text[i++] !== '"') return null;
let opaque = "";
while (i < text.length && text[i] !== '"') {
const code = text.charCodeAt(i);
if (!(code === 0x21 || (code >= 0x23 && code <= 0x7e)
|| (code >= 0x80 && code <= 0xff))) return null;
opaque += text[i++];
}
if (text[i++] !== '"') return null;
tags.push({ weak, opaque });
if (tags.length > 32) return null;
while (text[i] === " " || text[i] === "\t") i++;
if (i === text.length) break;
if (text[i++] !== ",") return null;
}
return { wildcard: false, tags };
}
function matches(value, currentValue, weakComparison) {
const candidates = parseTagList(value);
const current = parseTagList(currentValue);
if (!candidates || !current || current.tags.length !== 1) return null;
if (candidates.wildcard) return true;
const selected = current.tags[0];
return candidates.tags.some(tag =>
tag.opaque === selected.opaque
&& (weakComparison || (!tag.weak && !selected.weak))
);
}
The route evaluates If-None-Match before the date validator and emits the same cache metadata on 200 and 304:
const state = {
version: 7,
modifiedAt: new Date("2026-07-26T14:30:00Z"),
lastModifiedHighWater: Date.parse("2026-07-26T14:30:00Z"),
lastModifiedReliable: true,
body: { id: 42, title: "Conditional HTTP" }
};
const etag = () => `"article-v${state.version}"`;
const modifiedSecond = () =>
Math.floor(state.modifiedAt.getTime() / 1000) * 1000;
const metadata = () => {
const headers = {
"Cache-Control": "public, no-cache",
ETag: etag()
};
if (state.lastModifiedReliable) {
headers["Last-Modified"] = new Date(modifiedSecond()).toUTCString();
}
return headers;
};
const MONTHS = new Map(
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
.map((name, index) => [name, index])
);
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function utcTime(
year, month, day, hour, minute, second, dayName
) {
if (year < 1900 || month === undefined
|| hour > 23 || minute > 59 || second > 60) return null;
const leapSecond = second === 60;
if (leapSecond && (hour !== 23 || minute !== 59)) return null;
const date = new Date(0);
date.setUTCFullYear(year, month, day);
date.setUTCHours(hour, minute, Math.min(second, 59), 0);
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month
&& date.getUTCDate() === day
&& date.getUTCHours() === hour
&& date.getUTCMinutes() === minute
&& DAY_NAMES[date.getUTCDay()] === dayName.slice(0, 3)
? date.getTime() + (leapSecond ? 1000 : 0) : null;
}
function parseHttpDate(value, now = new Date()) {
let m = value.match(
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/
);
if (m) return utcTime(+m[3], MONTHS.get(m[2]), +m[1],
+m[4], +m[5], +m[6], m[0].slice(0, 3));
m = value.match(
/^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/
);
if (m) {
let year = 2000 + Number(m[3]);
if (year > now.getUTCFullYear() + 50) year -= 100;
return utcTime(year, MONTHS.get(m[2]), +m[1],
+m[4], +m[5], +m[6], m[0].slice(0, 3));
}
m = value.match(
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) (\d{2}| \d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/
);
return m ? utcTime(+m[6], MONTHS.get(m[1]), +m[2].trim(),
+m[3], +m[4], +m[5], m[0].slice(0, 3)) : null;
}
function evaluatePreconditions(req) {
const method = req.method.toUpperCase();
const ifMatch = req.get("If-Match");
if (ifMatch !== undefined) {
const matched = matches(ifMatch, etag(), false);
if (matched === null) return 400;
if (!matched) return 412;
} else {
const unmodifiedSince = req.get("If-Unmodified-Since");
const time = unmodifiedSince === undefined
? null : parseHttpDate(unmodifiedSince);
if (state.lastModifiedReliable
&& time !== null && modifiedSecond() > time) return 412;
}
const noneMatch = req.get("If-None-Match");
if (noneMatch !== undefined) {
const matched = matches(noneMatch, etag(), true);
if (matched === null) return 400;
if (matched) return method === "GET" || method === "HEAD"
? 304 : 412;
} else if (method === "GET" || method === "HEAD") {
const since = req.get("If-Modified-Since");
const time = since === undefined ? null : parseHttpDate(since);
if (state.lastModifiedReliable
&& time !== null && modifiedSecond() <= time) return 304;
}
return null;
}
function endConditional(res, status) {
res.status(status);
if (status === 304 || status === 412) res.set(metadata());
return res.end();
}
app.get("/api/articles/42", (req, res) => {
const status = evaluatePreconditions(req);
if (status !== null) return endConditional(res, status);
return res.status(200).set(metadata()).json(state.body);
});
function replaceArticle(title, now = new Date()) {
const nextSecond = Math.floor(now.getTime() / 1000) * 1000;
const reliable = nextSecond > state.lastModifiedHighWater;
state.body = { id: 42, title };
state.version += 1;
state.modifiedAt = new Date(nextSecond);
state.lastModifiedReliable = reliable;
if (reliable) state.lastModifiedHighWater = nextSecond;
}
app.put("/api/articles/42", (req, res) => {
const ifMatch = req.get("If-Match");
if (ifMatch === undefined) return res.status(428).end();
const status = evaluatePreconditions(req);
if (status !== null) return endConditional(res, status);
// No await occurs between comparison and mutation. In a database,
// use UPDATE ... WHERE version = state.version and check row count.
replaceArticle(req.body.title);
return res.status(200).set(metadata()).json(state.body);
});
evaluatePreconditions follows RFC 9110 Section 13.2.2: If-Match, If-Unmodified-Since only when If-Match is absent, If-None-Match, then If-Modified-Since for GET or HEAD. The API still requires If-Match on PUT, so an update without it is rejected as 428 before mutation.
The parser accepts all three HTTP-date forms, validates weekday and year, and maps 23:59:60 forward. lastModifiedHighWater makes dates globally monotonic: a same-second collision, rollback, or reused second disables date preconditions and removes Last-Modified. Persist it with the version so origins and intermediaries cannot return a false date-based 304.
Verify conditional requests with curl
This transcript shows 200, validating 304, then changed 200:
curl -i https://api.example.test/api/articles/42
HTTP/1.1 200 OK
Cache-Control: public, no-cache
ETag: "article-v7"
Last-Modified: Sun, 26 Jul 2026 14:30:00 GMT
Content-Type: application/json
{"id":42,"title":"Conditional HTTP"}
curl -i -H 'If-None-Match: "article-v7"' \
https://api.example.test/api/articles/42
HTTP/1.1 304 Not Modified
Cache-Control: public, no-cache
ETag: "article-v7"
Last-Modified: Sun, 26 Jul 2026 14:30:00 GMT
curl -i -H 'If-None-Match: "article-v7"' \
https://api.example.test/api/articles/42
HTTP/1.1 200 OK
Cache-Control: public, no-cache
ETag: "article-v8"
Last-Modified: Sun, 26 Jul 2026 14:34:10 GMT
Content-Type: application/json
{"id":42,"title":"Conditional HTTP, revised"}
Version 8 makes a stale update fail:
curl -i -X PUT -H 'Content-Type: application/json' \
-H 'If-Match: "article-v7"' \
--data '{"title":"My edit"}' \
https://api.example.test/api/articles/42
HTTP/1.1 412 Precondition Failed
ETag: "article-v8"
Tests for validators and preconditions
Spring MockMvc tests should pin comparison modes, precedence, metadata, and the empty 304 body:
@ParameterizedTest
@ValueSource(strings = {
"W/\"article-v7\"",
"\"other\", W/\"article-v7\"",
"*"
})
void matchingIfNoneMatchReturns304(String value) throws Exception {
mockMvc.perform(get("/api/articles/42")
.header(IF_NONE_MATCH, value))
.andExpect(status().isNotModified())
.andExpect(header().string(ETAG, "\"article-v7\""))
.andExpect(header().string(CACHE_CONTROL, "no-cache, public"))
.andExpect(content().string(""));
}
@Test
void ifNoneMatchTakesPrecedenceOverDate() throws Exception {
mockMvc.perform(get("/api/articles/42")
.header(IF_NONE_MATCH, "\"other\"")
.header(IF_MODIFIED_SINCE, "Sun, 26 Jul 2030 00:00:00 GMT"))
.andExpect(status().isOk());
}
@Test
void staleIfMatchOnGetPreserves412() throws Exception {
mockMvc.perform(get("/api/articles/42")
.header(IF_MATCH, "\"stale\""))
.andExpect(status().isPreconditionFailed())
.andExpect(header().string(CACHE_CONTROL, "no-cache, public"));
}
@Test
void oldIfUnmodifiedSinceOnGetPreserves412() throws Exception {
mockMvc.perform(get("/api/articles/42")
.header(IF_UNMODIFIED_SINCE,
"Sun, 26 Jul 2020 14:30:00 GMT"))
.andExpect(status().isPreconditionFailed())
.andExpect(header().string(CACHE_CONTROL, "no-cache, public"));
}
Supertest pins comma-bearing tags and updates:
assert.equal(matches('"other", W/"article-v7"', '"article-v7"', true), true);
assert.equal(matches('"release,7"', '"release,7"', true), true);
assert.equal(matches('W/"article-v7"', '"article-v7"', false), false);
const conditional = (method, headers) => ({
method,
get: name => headers[name]
});
assert.equal(evaluatePreconditions(
conditional("GET", { "If-Match": '"stale"' })
), 412);
assert.equal(evaluatePreconditions(conditional("PUT", {
"If-Match": '"article-v7"',
"If-None-Match": '"article-v7"'
})), 412);
assert.equal(evaluatePreconditions(conditional("GET", {
"If-Match": '"article-v7"',
"If-Unmodified-Since": "Sun, 26 Jul 2020 14:30:00 GMT"
})), null);
assert.equal(evaluatePreconditions(conditional("GET", {
"If-None-Match": '"other"',
"If-Modified-Since": "Fri, 26 Jul 2030 14:30:00 GMT"
})), null);
assert.equal(parseHttpDate("Mon, 26 Jul 2026 14:30:00 GMT"), null);
assert.equal(parseHttpDate("Wed, 26 Jul 1899 14:30:00 GMT"), null);
assert.equal(
parseHttpDate("Sat, 31 Dec 2016 23:59:60 GMT"),
Date.UTC(2017, 0, 1)
);
const stale = await request(app)
.put("/api/articles/42")
.set("If-Match", '"article-v6"')
.send({ title: "stale" });
assert.equal(stale.status, 412);
assert.equal(stale.headers.etag, '"article-v7"');
test("two visible writes in one second plus clock rollback and catch-up cannot reuse a date",
async () => {
replaceArticle("first write", new Date("2026-07-26T14:34:10.200Z"));
const firstDate = metadata()["Last-Modified"];
replaceArticle("second write", new Date("2026-07-26T14:34:10.800Z"));
const response = await request(app)
.get("/api/articles/42")
.set("If-Modified-Since", firstDate);
assert.equal(response.status, 200);
assert.equal(response.body.title, "second write");
assert.equal(response.headers["last-modified"], undefined);
assert.equal(
sharedCacheWouldReturnNotModified(response, firstDate),
false
);
replaceArticle("rollback", new Date("2026-07-26T14:33:10.100Z"));
replaceArticle("catch-up", new Date("2026-07-26T14:34:10.900Z"));
const caughtUp = await request(app)
.get("/api/articles/42")
.set("If-Modified-Since", firstDate);
assert.equal(caughtUp.status, 200);
assert.equal(caughtUp.headers["last-modified"], undefined);
assert.equal(sharedCacheWouldReturnNotModified(caughtUp, firstDate), false);
});
function sharedCacheWouldReturnNotModified(stored, since) {
const lastModified = stored.headers["last-modified"];
return lastModified !== undefined
&& Date.parse(lastModified) <= Date.parse(since);
}
Also test *, malformed lists, future dates, and a one-winner database update race.
Common mistakes
- Omitting quotes or treating
W/"x"as a strong tag. - Comparing one raw header string and ignoring lists or
*. - Splitting on commas even though a quoted opaque tag can contain one.
- Evaluating
If-Modified-Sinceafter a non-matchingIf-None-Match. - Sending content with
304. - Using weak comparison for
If-Match. - Checking a version and updating later without an atomic database condition.
Edge cases
Align validators with Vary. With no current representation, If-Match: * is false and If-None-Match: * is true. A false If-None-Match condition on an unsafe method yields 412, not 304.
Range recombination needs strong validators. Last-modified dates must not move into the future. Treat malformed conditionals consistently and reject mixed wildcard lists.
Practical checklist
- Identify the selected representation and every
Varydimension. - Generate a quoted stable strong ETag from deterministic bytes or a version.
- Evaluate
If-Matchbefore cache validators and use strong comparison. - Evaluate
If-None-MatchbeforeIf-Modified-Since. - Support lists,
*, weak comparison, and comma-bearing opaque tags. - Return
304without content and with relevant current cache metadata. - Make state comparison and mutation atomic; return
412when stale. - Test
200→304→ changed200on the wire.
Frequently asked questions
Should an ETag be a database version or a content hash?
Either can be correct. A version is cheaper when it changes for every representation change. A hash is safer when it is computed from the exact deterministic bytes returned, but it still costs serialization and hashing.
Can a weak ETag return 304?
Yes. If-None-Match uses weak comparison for cache validation. Weak validators must not satisfy the strong comparison required by If-Match.
Does If-Match replace database concurrency control?
No. It carries the client’s expected state over HTTP. The database operation must still enforce that version atomically.
Sources
- RFC 9110 Section 8.8.3: ETag
- RFC 9110 Section 13.1: conditional request preconditions
- RFC 9110 Section 13.2.2: precedence of preconditions
- RFC 9110 Section 15.4.5: 304 Not Modified
- RFC 9111 Section 4.3: validation
- Spring Framework HTTP caching documentation
- Express 4
req.freshAPI freshv0.5.2 source
Related reading
- Start with HTTP Cache Headers Explained.
- Choose policies in Cache-Control Explained for APIs.
- Apply validators at the edge in CDN Caching Strategies for APIs.
- Protect private responses with Prevent Sensitive Data Caching.
- Continue through API Design or browse topic clusters.