Conditional Requests Are an Interview Contract: Build an ETag Drill in Node.js
An ETag is not just a cache header. In a backend interview, it is a compact way to show that you can define an HTTP contract, avoid needless transfer, and prevent an older edit from silently overwriting a newer one. This small Node.js drill implements both paths: 304 Not Modified for cache revalidation and 412 Precondition Failed for stale writes.
A common interview prompt sounds deceptively simple: "How would you add caching to this profile endpoint?" A weak answer jumps to Redis. A stronger one first asks what a client is allowed to reuse, how it knows that a representation changed, and what happens when two clients edit the same record.
HTTP already gives us useful vocabulary:
| Request | Server condition | Result | Why it matters |
|---|---|---|---|
GET with If-None-Match
|
Client's validator still matches |
304, no body |
The client reuses its cached representation |
PUT with If-Match
|
Client's validator matches current state | 200 |
The write is based on the version the client read |
PUT with If-Match
|
Validator is stale | 412 |
The server refuses a lost update |
PUT without If-Match
|
This API requires a precondition | 428 |
A caller cannot accidentally bypass concurrency control |
The semantics come from RFC 9110's conditional-request section. The important part is not memorizing status codes. It is making the resource lifecycle explicit.
What are we proving?
Our tiny endpoint will preserve four properties:
- A client that already has the current representation gets a
304and no response body. - A changed representation gets a new ETag.
- A write without an
If-Matchprecondition is rejected for this resource. - A stale write cannot overwrite the current record.
That is enough to practice a complete answer without hiding the hard parts behind a framework.
Build the smallest useful contract
Save this as etag-drill.js and run it with Node 18 or later. It has no dependencies.
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const etag = (value) =>
`"${crypto
.createHash("sha256")
.update(JSON.stringify(value))
.digest("base64url")}"`;
function getProfile(headers, profile) {
const tag = etag(profile);
const cacheHeaders = {
etag: tag,
"cache-control": "private, max-age=60",
};
if (headers["if-none-match"] === tag) {
return { status: 304, headers: cacheHeaders, body: null };
}
return { status: 200, headers: cacheHeaders, body: profile };
}
function updateProfile(headers, patch, current) {
const currentTag = etag(current);
if (!headers["if-match"]) {
return {
status: 428,
headers: { etag: currentTag },
body: { error: "If-Match required" },
};
}
if (headers["if-match"] !== currentTag) {
return {
status: 412,
headers: { etag: currentTag },
body: { error: "stale representation" },
};
}
const next = {
...current,
...patch,
version: current.version + 1,
};
return {
status: 200,
headers: { etag: etag(next) },
body: next,
};
}
const profile = { id: "u1", displayName: "Mina", version: 7 };
const firstRead = getProfile({}, profile);
assert.equal(firstRead.status, 200);
assert.deepEqual(firstRead.body, profile);
const cachedRead = getProfile(
{ "if-none-match": firstRead.headers.etag },
profile,
);
assert.equal(cachedRead.status, 304);
assert.equal(cachedRead.body, null);
assert.equal(
updateProfile({}, { displayName: "Mina K." }, profile).status,
428,
);
assert.equal(
updateProfile(
{ "if-match": "\"old\"" },
{ displayName: "Mina K." },
profile,
).status,
412,
);
const accepted = updateProfile(
{ "if-match": firstRead.headers.etag },
{ displayName: "Mina K." },
profile,
);
assert.equal(accepted.status, 200);
assert.equal(accepted.body.version, 8);
assert.notEqual(accepted.headers.etag, firstRead.headers.etag);
console.log("etag contract assertions passed");
The hash is only a stand-in for a production validator. The point is that every representation has a stable, opaque identifier, and a material change produces a different one. Clients should treat an ETag as opaque; they do not need to know whether it came from a version column, a content hash, or an object-store generation number.
Why does 304 need an empty body?
A 304 means "your stored representation is still valid." Sending the body again defeats the main purpose: a browser, mobile app, or CDN can reuse bytes it already owns. The response should still include the relevant cache metadata, especially the ETag and cache-control policy.
The private directive is intentional here. A profile may vary by viewer or contain user-specific fields, so a shared cache must not serve one person's representation to another. For a truly public resource, public can be appropriate, but only after checking authorization, locale, content encoding, and every other input that changes the response.
That caveat is a useful interview signal. "Add an ETag" is incomplete until you say what exact representation it validates.
Why is If-Match different from cache revalidation?
Cache revalidation asks, "Can I reuse what I read?" A guarded write asks, "Am I still editing what I read?"
Imagine two browser tabs:
- Both read profile version 7 and receive ETag A.
- Tab one changes the display name. The server accepts ETag A, stores version 8, and returns ETag B.
- Tab two submits an older form with ETag A.
- The server sees that A is no longer current and returns
412 Precondition Failed.
Without the precondition, tab two may silently erase tab one's change. With it, the client can refetch, show a conflict UI, or ask the user to merge the fields.
Notice that the example requires If-Match and returns 428 Precondition Required when it is absent. That policy is not universal. It is appropriate when a blind write would be harmful. For an append-only event endpoint, insisting on a representation validator could be needless friction. State the resource-specific rule rather than claiming one status code fits every API.
What would change in a real service?
The drill intentionally keeps state in one object. Production code needs a few decisions that should be named out loud.
- Generate validators from durable state. A database version column is often cheaper and clearer than hashing a large serialized response.
-
Make the comparison atomic with the write. In SQL, update with both the record ID and expected version in the
WHEREclause, then check whether one row changed. Reading, comparing, and writing in separate transactions reintroduces the race. - Define the representation boundary. If permissions or feature flags change the returned fields, the validator must reflect the variant the caller saw.
-
Use weak ETags carefully. Weak validators are useful for semantic cache equivalence, but
If-Matchis about avoiding conflicting updates and needs a strong validator. -
Measure the result. Track revalidation rate,
304rate,412conflicts, and payload bytes saved. A cache is a performance feature only if the access pattern makes it one.
Those choices turn a header-level answer into a system-design answer.
How would I say this in an interview?
A concise answer can follow this sequence:
"For reads, I would return an ETag for the exact authorized representation. Clients send it back in If-None-Match; unchanged data gets a 304 without a body. For edits, I would require If-Match and atomically compare a durable version during the update. A mismatch becomes 412 so the client can refetch instead of overwriting a newer edit. I would scope cache-control to the data sensitivity and monitor both 304 savings and 412 conflicts."
Then invite the follow-up: "Do you want the database query for the atomic update, or the client conflict-recovery flow?" That shows you have a next level without burying the interviewer in implementation detail.
For practice, say the explanation while running the assertions, then have a partner challenge one assumption at a time. An interview practice tool such as aceround.app can be useful here because the value is in responding to the follow-up, not reciting the first answer.
FAQ
Is an ETag a security control?
No. It helps a client validate freshness and can support optimistic concurrency. Authorization must still happen before a representation is read or updated. Do not rely on an unguessable-looking ETag as access control.
Should every GET return an ETag?
Not automatically. It adds the most value when clients revisit a resource and the response body is worth avoiding. Start from request patterns and payload size, then measure.
Why not use a timestamp?
A timestamp can work, but clocks and precision create awkward boundaries. A monotonic version is usually easier to compare atomically. The contract matters more than the token format.
What does the client do after a 412?
It fetches the current representation, compares it with the user's pending edit, and either retries with the new validator or asks the user to resolve a meaningful conflict. Never blindly retry an old patch without deciding how fields should merge.
AI assistance disclosure: AI helped draft and edit this article. The example, HTTP semantics, code execution, and technical claims were reviewed before publication.
Top comments (0)