An ETag is a short opaque string a server attaches to a response to say “this is version X of this content”. Clients send it back later in a conditional header, and the server answers based on whether the content still matches. That one mechanism does two quite different jobs: it makes repeat reads cheap, and it stops two people editing the same thing from silently overwriting each other.
Interviewers usually ask about the first (what’s a 304?) and then probe the second (how do you prevent lost updates in a REST API?). Candidates who know both, and know they share the same headers, stand out. The examples below run against a small Node server holding one document.
What an ETag identifies
An ETag belongs to a representation, not just a resource. The same URL served as gzip and as plain text, or as JSON and as XML, produces different bytes, and a strong ETag promises byte-for-byte identity. If a cache or proxy compresses a response, it’s expected to change or weaken the tag, which is why you’ll often see a strong tag from the origin arrive with a W/ prefix after passing through a compressing proxy.
That prefix marks a weak ETag: “semantically the same, maybe not identical bytes”. The two kinds compare differently:
If-None-Match(cache revalidation) uses weak comparison, soW/"abc"matches"abc".If-Match(conditional writes) requires strong comparison, so a weak tag never satisfies it.
That matters in practice. Express generates weak ETags by default, which work for caching but can’t protect writes.
How you generate the tag is a design choice. Hashing the response body is simple and always correct, but you have to build the whole body before you can answer, so a 304 saves bandwidth, not server work. A version number or updated_at column is cheaper: the server can answer a conditional request before rendering anything. Whatever you pick, every instance behind the load balancer must produce the same tag for the same content. Old Apache defaults built ETags partly from file inode numbers, which differed per server, so revalidation kept failing behind a load balancer.
Revalidating a cached read
The test server builds its ETag from a version number plus a short content hash, and handles GET like this:
const etagOf = (d) => `"v${d.version}-${createHash('sha256').update(JSON.stringify(d)).digest('hex').slice(0, 8)}"`;
if (req.method === 'GET') {
if (req.headers['if-none-match'] === current) { // the client's copy is still good
res.writeHead(304, { etag: current });
return res.end();
}
res.writeHead(200, { 'content-type': 'application/json', etag: current, 'cache-control': 'no-cache' });
return res.end(JSON.stringify(doc) + '\n');
}The first request gets the full body and the tag:
HTTP/1.1 200 OK
content-type: application/json
etag: "v1-e0911ab1"
cache-control: no-cache
{"id":7,"title":"Runbook","body":"Restart the worker.","version":1}A client holding that copy sends the tag back:
curl -si http://127.0.0.1:8093/docs/7 -H 'If-None-Match: "v1-e0911ab1"'HTTP/1.1 304 Not Modified
etag: "v1-e0911ab1"No body. For a 70-byte document that’s trivial; for a 2 MB JSON export polled by thousands of dashboards, it’s most of your bandwidth bill. Note what isn’t saved: the round trip. A 304 still needs a request and a response. To skip the request entirely, the response also needs a freshness lifetime (max-age). no-cache here tells caches to revalidate every time, which keeps clients current at the cost of one small round trip.
A real server should also handle the edge cases this demo skips: If-None-Match can carry a list of tags or *, and a 304 should repeat headers like Cache-Control and Vary that the 200 would have sent.
Preventing a lost update
Now the write side. Two editors, A and B, both open document 7 and receive "v1-e0911ab1". A saves first, sending the tag in If-Match:
curl -si -X PUT http://127.0.0.1:8093/docs/7 -H 'If-Match: "v1-e0911ab1"' \
-d '{"body":"Restart the worker, then check the queue depth."}'HTTP/1.1 200 OK
content-type: application/json
etag: "v2-09cdeccb"
{"id":7,"title":"Runbook","body":"Restart the worker, then check the queue depth.","version":2}B, still working from version 1, saves a minute later with the same old tag:
HTTP/1.1 412 Precondition Failed
content-type: text/plain
etag: "v2-09cdeccb"
the document changed since you loaded itWithout the condition, B’s save would have replaced A’s change and nobody would have noticed until the runbook was wrong during an incident. That’s the lost update problem, and this is optimistic concurrency control: no locks while people edit, just a check at save time.
The server also refuses writes that don’t make a claim at all:
HTTP/1.1 428 Precondition Required
content-type: text/plain
send If-Match with the ETag you edited428 (from RFC 6585) forces every client to opt into the check. Without it, one careless client that never sends If-Match reintroduces last-write-wins for everyone.
The check and the write must happen together
Here’s the server’s PUT handler, and a bug that was fixed in it while writing this note:
if (req.method === 'PUT') {
let raw = '';
for await (const chunk of req) raw += chunk; // read the body first, so check and write happen together
const condition = req.headers['if-match'];
if (!condition) { res.writeHead(428, { 'content-type': 'text/plain' }); return res.end('send If-Match with the ETag you edited\n'); }
if (condition !== etagOf(doc)) { // someone else saved first
res.writeHead(412, { 'content-type': 'text/plain', etag: etagOf(doc) });
return res.end('the document changed since you loaded it\n');
}
Object.assign(doc, JSON.parse(raw), { version: doc.version + 1 });
// ...respond with the new ETag
}The first version compared the tag, then awaited the request body, then wrote. Two PUTs with the same tag could both pass the check while waiting for their bodies, and the second would overwrite the first anyway. Moving the await before the check makes the compare and write run without interruption in Node’s single thread.
With a database, the equivalent is to put the condition inside the write itself:
UPDATE docs SET body = $1, version = version + 1
WHERE id = 7 AND version = 1;If it updates zero rows, someone else got there first: return 412. Reading the version in one query and writing in another has the same race as the original handler.
The other half is what the client does with a 412. Silently retrying with the new tag defeats the point; it’s last-write-wins with extra steps. The client should fetch the current version and either merge automatically (if the changes touch different fields) or show the user both versions.
Interview practice
Several admins edit a shared settings page through your API, and hundreds of services poll those settings every 30 seconds. Design the API so admins can’t silently overwrite each other’s changes, and so polling stays cheap.
Answer
I’d keep a version number on the settings record and expose it as a strong ETag on GET /settings. Pollers send If-None-Match with the last tag they saw, and the server compares it against the version, which it can read without building the full response. Most polls then get a body-less 304. I’d add Cache-Control: no-cache so any cache in between revalidates rather than serving stale settings.
For edits, PUT (or PATCH) must include If-Match. Without it the server returns 428, so no client can skip the check. The update runs as a single statement with WHERE version = :expected, and zero updated rows means another admin saved first, so the server returns 412 with the current ETag. The admin UI then reloads the latest settings and shows the user what changed before they reapply their edit, rather than retrying blindly. Because the version comes from the database, every API instance produces the same tag, so revalidation works behind the load balancer.