QA How-To
Testing conditional requests with ETags (2026)
Learn testing conditional requests with ETags for caching, optimistic concurrency, strong and weak validators, 304 and 412 responses, proxies, and race cases.
23 min read | 3,658 words
TL;DR
Testing conditional requests with ETags means capturing a validator from a representation, sending it through If-None-Match or If-Match, changing the resource, and repeating the request at both matching and stale states. Verify 304 and 412 semantics, empty bodies, comparison strength, validator changes, representation variants, and concurrent update safety.
Key Takeaways
- Treat an ETag as an opaque quoted validator and never infer business meaning from its characters.
- Use If-None-Match on GET or HEAD to test cache revalidation and expect 304 without a response body when the validator matches.
- Use If-Match on state-changing requests to prevent lost updates and expect 412 when the current representation no longer matches.
- Test strong and weak comparison rules because If-Match requires strong comparison while If-None-Match uses weak comparison.
- Verify validators across content negotiation, compression, replicas, deployments, caches, and rapid consecutive writes.
- Run two-client race scenarios and reconcile final state, version, response headers, and side effects.
Testing conditional requests with ETags proves two different capabilities: efficient cache revalidation and safe state changes under concurrency. A client can ask for a representation only if it changed, or request an update only if the resource still matches the version previously read. QA must validate both the status code and the representation or mutation semantics behind it.
ETags are HTTP validators, not version numbers that clients may parse. This guide builds a protocol-accurate test model for ETag, If-None-Match, and If-Match, then extends it to weak validators, content negotiation, intermediaries, rapid writes, and two-client races.
TL;DR
| Request pattern | Matching condition | Expected result | Primary use |
|---|---|---|---|
GET plus If-None-Match |
Current validator matches | 304, no response body | Cache revalidation |
GET plus If-None-Match |
Validator is stale | 200 with current representation and validator | Refresh cache |
PUT or PATCH plus If-Match |
Current validator matches strongly | Perform method, return normal success | Prevent lost update |
PUT or PATCH plus If-Match |
Validator is stale or weak | 412, do not perform method | Reject stale writer |
Create plus If-None-Match: * |
No current representation | Perform method | Create only if absent |
State change plus If-Match: * |
A current representation exists | Perform method | Require existing resource |
Always capture the validator exactly as received, including quotes and any W/ prefix. The value is opaque. A correct test compares externally observable behavior and must not assume that ETags increment, contain a row version, or equal a content hash.
1. Understand the Validator Contract Before Testing
An origin server sends ETag with a selected representation. The field value is an entity tag: an opaque value in double quotes, optionally prefixed by W/ to mark a weak validator. For example, a response may carry a strong tag such as "rev-42" or a weak tag such as W/"rev-42". The client sends the received value in a conditional request.
Strong validators identify representations suitable for byte-sensitive comparisons and concurrency control. Weak validators indicate semantic equivalence while allowing changes that the server considers insignificant. A server can choose its generation algorithm and can change that algorithm. Therefore tests should not decode, sort, increment, or rebuild tags.
Identify which representation varies. The same resource can produce JSON and HTML, compressed and uncompressed content, localized fields, or user-specific projections. A validator must be correct for the selected representation and the server's comparison rules. If Vary: Accept-Encoding or Vary: Accept-Language applies, caches and tests need to preserve those request headers.
Document the product's intended use:
- Cache revalidation for GET and HEAD.
- Optimistic concurrency for PUT, PATCH, DELETE, or domain commands.
- Create-if-absent behavior.
- Range request validation with
If-Range, if supported. - Mandatory preconditions, sometimes expressed through a 428 response policy.
Do not assume an ETag on GET automatically protects updates. The state-changing endpoint must evaluate If-Match against the relevant current representation or resource version before applying the mutation. API contract testing with Pact can protect header presence and response shapes, while direct behavioral tests verify server state transitions.
2. Know Strong and Weak Comparison Rules
The comparison rule depends on the conditional header. If-Match uses strong comparison. A weak entity tag never strongly matches, even if the opaque portions look identical. If-None-Match uses weak comparison, so strong and weak forms with the same opaque tag compare as equivalent for that condition.
| Candidate condition | Current ETag | If-Match result | If-None-Match result |
|---|---|---|---|
"a" |
"a" |
Match | Match for exclusion |
W/"a" |
"a" |
No strong match | Weak match for exclusion |
"a" |
W/"a" |
No strong match | Weak match for exclusion |
"a" |
"b" |
No match | No match |
* |
Current representation exists | Match | Match for exclusion |
A conditional field can contain a comma-separated list of entity tags. Test the current tag first, middle, and last in the list, along with unrelated tags. Do not split on commas with an improvised client parser because the HTTP library should transmit the field value as provided. On the server, use standards-compliant parsing rather than substring matching.
Whitespace and malformed syntax need negative coverage according to the framework and API contract. An unquoted token is not a valid entity tag. A server may reject a malformed field or handle it according to its HTTP stack, but it must not treat a crafted substring as a valid match. Capture behavior at the public boundary, including any gateway normalization.
Tags are case-sensitive opaque values. Test "Rev-A" versus "rev-a" if the generator can produce letters. Also test values after a generation strategy rollout. The server must compare validators consistently across instances during deployment, or valid clients can receive avoidable misses and stale writers may be handled inconsistently.
3. Build a Baseline for Testing Conditional Requests with ETags
Begin with a deterministic synthetic resource and two independent clients, A and B. Create or reset the resource through an approved fixture, then GET it and record status, body, Content-Type, Content-Encoding, Vary, cache fields, and ETag. Assert the tag has valid quoted syntax without asserting its internal text.
Run this baseline sequence:
- Client A sends an unconditional GET and stores body A and tag A.
- Client A repeats GET with
If-None-Match: tag A. - Verify 304, no message body, and the headers required by the product and HTTP cache behavior.
- Client B changes a meaningful resource field successfully.
- Client A repeats GET with
If-None-Match: tag A. - Verify 200, body B, and tag B appropriate to the changed representation.
- Repeat the revalidation with tag B and expect 304.
Then build the write sequence. Both clients GET tag B. Client B updates with If-Match: tag B and succeeds. Client A sends a different update with the now-stale tag B and receives 412. Read again and prove that Client A's fields, events, and audit result did not overwrite Client B's change.
Run a positive update as well as a stale update. A service that always returns 412 looks safe in negative tests but is unusable. Verify whether the success response includes the new representation and new ETag or requires a follow-up GET. Keep the test aligned with the documented response contract.
Use fresh HTTP requests and disable automatic client cache behavior when inspecting origin semantics. Then run a separate browser or proxy test with normal caching enabled. Mixing these layers in the baseline makes it difficult to determine whether a 304 came from the origin, an intermediary, or a client cache.
4. Test If-None-Match for GET and HEAD Revalidation
For GET and HEAD, a matching If-None-Match condition causes the origin to respond 304 Not Modified rather than transfer the selected representation. A 304 response ends after the header section and cannot contain a message body. Test body length as observed by the client, not merely the absence of a JSON property.
Verify the expected metadata on 304. Depending on the response, this includes cache-relevant fields such as ETag, Cache-Control, Expires, Date, and Vary. Compare the validator with the current selected representation. A client needs sufficient metadata to update its stored response correctly.
A non-matching condition should produce the normal 200 response for GET with the current body and validator. Test a single stale tag, a list containing stale and current tags, and *. On a resource that exists, If-None-Match: * prevents transfer for GET. On a missing resource, the condition does not match, so normal not-found behavior applies.
HEAD should follow GET's selection and conditional semantics while omitting a response body. Compare status and representation metadata for conditional GET and HEAD. Be careful with test clients that transparently transform HEAD responses or hide zero-length body details.
When both If-None-Match and If-Modified-Since are present, If-None-Match takes precedence for recipients that support it. Create a deliberately conflicting pair: an ETag indicating modified and a date suggesting not modified, then verify the ETag condition drives the result. This catches implementations that combine validators incorrectly.
Test multiple quick modifications within the same second. HTTP dates have limited granularity, while correctly generated ETags can distinguish revisions more precisely. This is one reason ETags are preferable for exact revalidation and optimistic concurrency, but only if the server changes the strong tag for every relevant representation change.
5. Test If-Match for Optimistic Concurrency
If-Match asks the origin to perform the method only if the current selected representation strongly matches one of the supplied entity tags. When the condition is false, the usual response is 412 Precondition Failed and the requested method must not be applied. This protects a read-edit-write client from overwriting a change committed after its read.
Test PUT, PATCH, DELETE, and domain action endpoints that advertise this control. For each, cover current tag, stale tag, unrelated tag, weak form of the current tag, tag list, and *. A weak tag must not satisfy If-Match. The wildcard should match when a current representation exists and fail when none exists.
State verification is essential. After 412, GET the resource and check version, fields, relationships, and business events. For DELETE, confirm the resource remains. For a money or inventory operation, reconcile balances or reservations. A precondition check performed after a side effect is a serious defect even if the response is 412.
Some servers make preconditions mandatory for selected updates. In that contract, omit If-Match and verify the documented response, often 428 Precondition Required. Do not demand 428 unless the API promises mandatory conditions. An API may accept unconditional writes by design, although that choice should be assessed against lost-update risk.
Also test authorization separately. Possessing a valid ETag is not permission. Another user who learns a validator must still fail authorization, and the response must not reveal whether the tag was current for a protected object. Access control should not be replaced by conditional request logic.
For update success, verify that the next ETag changes when the selected representation changes meaningfully under a strong-validator contract. If an update changes only metadata excluded from the representation, clarify whether the validator should change. The oracle comes from representation semantics, not from an assumption that every database write needs a new tag.
6. Exercise Wildcards, Multiple Tags, and Header Precedence
If-None-Match: * is useful for create-only-if-absent operations. Send it when the target does not exist and verify successful creation. Repeat against the now-existing target and expect 412 for a non-GET method, with the original resource unchanged. Race two create requests and verify exactly one creation wins according to the product contract.
If-Match: * requires a current representation. Use it for update-only-if-present behavior, then test existing, deleted, and never-created resources. This condition does not detect which version exists, so it prevents accidental creation but does not by itself prevent overwriting a concurrent update.
For comma-separated tag lists, include one current validator among several stale validators. If-Match should allow when a strong current tag is in the list. If-None-Match on GET should return 304 when any listed tag weakly matches. Include a weak candidate with the current opaque value to distinguish the comparison algorithms.
Conditional fields have defined evaluation order. If-Match is evaluated before If-Unmodified-Since, and a recipient that supports If-Match ignores If-Unmodified-Since when both are present. If-None-Match takes precedence over If-Modified-Since. Build conflicting values to reveal implementations that require every date and tag condition independently or choose dates first.
If-Range is different. It controls whether a range response can be used or whether the full representation should be sent. It is not a general precondition that produces 304 or 412. Test it only when range downloads are in scope, using a strong validator and verifying 206 versus complete 200 behavior according to the resource and range contract.
Header names are case-insensitive, but entity tag values are not. Gateways may combine repeated field lines or enforce size limits. Include one integration case through the real edge path because a direct service test cannot reveal header stripping or incorrect combination by an intermediary.
7. Test Representation Variants, Caches, and Proxies
An ETag validates a selected representation, not an abstract database row in every context. Request the same URI with different Accept, Accept-Language, compression, projection, and authorization context supported by the API. Record body, Vary, cache policy, and tag. A cache must not reuse a validator-body pair across variants that are not equivalent.
Compression deserves attention. Some servers generate distinct strong validators for encoded and unencoded bytes. Others use weak validators where semantic equivalence is sufficient. Either can be correct if comparison and caching remain consistent. Test through the production-like gateway because compression may happen after the application generates its header.
Private or user-specific resources must not leak through shared caches. Send a privileged request, then an ordinary request to the same URL with conditional headers. Verify cache controls and variation prevent the ordinary client from receiving privileged content or learning private validator behavior. Reverse the order too.
Simulate a cache revalidation after origin change. The cache should forward the condition, update stored metadata on 304, and replace the stored representation on 200. Test stale-if-error or other extensions only when explicitly configured. Avoid assuming which layer adds Age, strips a body, or synthesizes warnings.
A proxy can transform content. If it changes bytes while forwarding a strong origin ETag unchanged, range and byte-level assumptions may become unsafe. Inspect the client-visible representation and validator after all transformations. Capture headers at the origin and edge when possible to locate the defect.
Contract tests should verify stable public behavior, not freeze a specific generated tag. An assertion such as 'ETag is quoted and revalidates the returned body' survives hashing changes. An assertion such as 'ETag equals MD5 of JSON' couples the suite to an undocumented implementation and may encourage unsafe design.
8. Run Two-Client Race and Failure Scenarios
The highest-value concurrency case uses two clients. A and B both GET version 1 with tag 1. A updates field X with If-Match: tag 1 and receives success plus tag 2. B updates field Y with If-Match: tag 1. B must receive 412 and must not erase A's update. B then GETs version 2, reapplies its intended change, and updates with tag 2 according to the application's merge workflow.
Use a synchronization barrier so both initial reads complete before either write. Repeat with reversed winner and with simultaneous writes. Do not assert which client wins unless the system defines ordering. Assert that at most one stale-sensitive mutation succeeds from the same starting validator and that final state matches one permitted serialization.
Test response loss after commit. If A's update commits but the client times out before receiving tag 2, a retry with tag 1 may receive 412 because the resource changed. The client needs a recovery path: GET current state, compare the intended operation, and decide whether to retry. If the mutation is also idempotent, test how its idempotency key interacts with the stale condition. The API idempotency testing guide covers that second dimension.
Exercise rapid updates and replica reads. After a write succeeds, an immediate GET routed to a lagging replica must not return an older body with a validator that lets the client overwrite newer primary state. The exact solution is architectural, but the external invariant is no lost accepted change. Use correlation IDs and version-aware diagnostic data without requiring clients to interpret the ETag.
Restart and deploy scenarios matter when validators depend on process-local counters, unstable JSON serialization, or instance-specific secrets. The same unchanged selected representation should remain safely revalidatable across instances, or the cache will miss unnecessarily. More importantly, a stale tag must never become a false strong match for a different representation after restart.
9. Automate Testing Conditional Requests with ETags
The following Node.js 20+ file starts a local server with GET and PUT. It uses a strong quoted ETag, handles If-None-Match for revalidation, and enforces If-Match for updates. Save it as etag.test.mjs, then run node --test etag.test.mjs.
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
let resource = { name: 'draft', version: 1 };
const quote = String.fromCharCode(34);
const currentTag = () => `${quote}v${resource.version}${quote}`;
const server = createServer((request, response) => {
if (request.url !== '/document') {
response.writeHead(404).end();
return;
}
if (request.method === 'GET') {
const tag = currentTag();
if (request.headers['if-none-match'] === tag) {
response.writeHead(304, { etag: tag, 'cache-control': 'private, max-age=0' }).end();
return;
}
response.writeHead(200, { etag: tag, 'content-type': 'application/json' });
response.end(JSON.stringify(resource));
return;
}
if (request.method === 'PUT') {
if (request.headers['if-match'] !== currentTag()) {
response.writeHead(412, { etag: currentTag() }).end();
return;
}
let body = '';
request.setEncoding('utf8');
request.on('data', chunk => { body += chunk; });
request.on('end', () => {
resource = { name: JSON.parse(body).name, version: resource.version + 1 };
response.writeHead(204, { etag: currentTag() }).end();
});
return;
}
response.writeHead(405, { allow: 'GET, PUT' }).end();
});
let baseUrl;
before(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
});
test('matching If-None-Match returns 304 with no body', async () => {
const first = await fetch(`${baseUrl}/document`);
const tag = first.headers.get('etag');
assert.equal(first.status, 200);
const cached = await fetch(`${baseUrl}/document`, { headers: { 'if-none-match': tag } });
assert.equal(cached.status, 304);
assert.equal(await cached.text(), '');
assert.equal(cached.headers.get('etag'), tag);
});
test('stale writer receives 412 and cannot overwrite winner', async () => {
const read = await fetch(`${baseUrl}/document`);
const staleTag = read.headers.get('etag');
const headers = { 'content-type': 'application/json', 'if-match': staleTag };
const winner = await fetch(`${baseUrl}/document`, { method: 'PUT', headers, body: JSON.stringify({ name: 'winner' }) });
assert.equal(winner.status, 204);
const loser = await fetch(`${baseUrl}/document`, { method: 'PUT', headers, body: JSON.stringify({ name: 'loser' }) });
assert.equal(loser.status, 412);
const finalResponse = await fetch(`${baseUrl}/document`);
assert.equal((await finalResponse.json()).name, 'winner');
assert.notEqual(finalResponse.headers.get('etag'), staleTag);
});
The sample compares one tag for clarity. A production implementation must use a standards-compliant parser for lists, wildcard rules, and strong or weak comparison. The tests should keep the received value opaque and add integration coverage through any gateway or CDN.
10. Diagnose and Report Conditional Request Defects
Capture a minimal chronological transcript: resource fixture, unconditional response, validator, conditional request, intervening mutation, conditional response, and final resource state. Preserve raw headers because many HTTP clients hide quotes, merge fields, or automatically use caches. Include whether traffic passed through a gateway, CDN, service mesh, or replica.
Classify the failure precisely. Examples include validator absent, malformed tag, unchanged strong tag after representation change, needless tag change without representation change, wrong comparison strength, incorrect status, 304 with body, stale mutation applied, missing new validator, cache variant confusion, or inconsistent instance behavior. Each points to a different layer.
For a stale write defect, state impact in business terms: Client B overwrote Client A's accepted address change, not merely 'expected 412, got 204.' Include the final state and any duplicate events. A protocol mismatch with no data loss may have lower impact than a silent lost update, even when both violate the same header contract.
Avoid brittle time assertions. Use controlled state transitions and observable validators. If dates are part of precedence testing, inject or freeze the application clock when supported. Do not wait a second to make a test pass because that masks inadequate validator precision.
Add regression coverage at the narrowest useful layers: unit tests for comparison and tag generation, service tests for state transition and side effects, and edge tests for header forwarding, compression, and cache behavior. API error handling and negative testing helps keep 412 bodies stable and safe.
When a conflict is a domain rule rather than an HTTP precondition, the service may use 409. Keep the distinction explicit: 412 says a request precondition evaluated false, while 409 usually describes a conflict with current resource state that the client may resolve. Review HTTP 409 Conflict for domain examples.
Interview Questions and Answers
Q: What is an ETag?
An ETag is an opaque HTTP validator associated with a selected representation. The server generates it, the client returns it in conditional headers, and neither client nor test should infer meaning from its internal characters.
Q: How does If-None-Match support caching?
The client sends a previously stored tag on GET or HEAD. If the current selected representation weakly matches, the server returns 304 without a body. Otherwise it returns the normal current representation, commonly 200 with a new or current ETag.
Q: How does If-Match prevent lost updates?
A client reads a representation and later sends its tag with an update. The server applies the update only if that tag strongly matches the current representation, so a client holding a stale tag receives 412 and cannot overwrite an intervening change.
Q: What is the difference between strong and weak ETags?
A strong tag is suitable for strong comparison and byte-sensitive uses defined by HTTP semantics. A weak tag begins with W/ and signals semantic equivalence rather than strong identity. If-Match requires strong comparison, while If-None-Match uses weak comparison.
Q: What should you assert on a 304 response?
I assert 304 status, no message body, correct current validator, and relevant cache metadata such as cache controls and variation fields. I also prove that a stale condition returns the normal representation instead.
Q: Is a valid If-Match header authorization?
No. It is a state precondition, not proof that the caller may update the resource. Authentication and authorization must still be enforced, ideally without revealing validator state for an inaccessible object.
Q: How would you test two concurrent writers?
Both clients read the same tag, then attempt different updates with that tag. I synchronize the writes, expect no more than one stale-sensitive success, and verify final state matches a permitted order with no lost or duplicated side effect.
Q: When would you use 409 instead of 412?
I use the API contract as authority. A false HTTP precondition such as stale If-Match maps naturally to 412, while a domain conflict that is not expressed as a request precondition often maps to 409.
Common Mistakes
- Removing quotes from the ETag before sending it back.
- Treating the tag as an integer version or content hash that tests can reconstruct.
- Using weak tags with If-Match and expecting a strong match.
- Asserting 304 status without proving the response has no body and correct cache metadata.
- Checking 412 but not verifying that the stale mutation and downstream events did not occur.
- Testing one client only and missing lost-update races.
- Requiring the same ETag across JSON, HTML, language, or compression variants without checking representation semantics.
- Bypassing the gateway or CDN in every test and missing stripped or transformed headers.
- Using fixed sleeps to separate revisions instead of testing rapid changes.
- Confusing an ETag precondition with authentication, authorization, or idempotency.
Conclusion
Testing conditional requests with ETags is successful when validators remain opaque, comparison rules are correct, cache revalidation avoids unnecessary bodies, and stale writers cannot damage accepted state. The most convincing evidence comes from controlled representation changes and two-client timelines, not hard-coded tag values.
Create one synthetic resource, capture its validator, and run the baseline GET, 304, successful update, stale 412, and final-state sequence. Then extend that sequence through the real gateway, representation variants, and replica topology that your users actually reach.
Interview Questions and Answers
How would you test ETag cache revalidation?
I GET a controlled resource, preserve its ETag exactly, and repeat GET with If-None-Match. I assert 304, no body, and correct cache metadata, then change the representation and assert the stale tag produces 200 with current content and validator.
How would you test optimistic locking with ETags?
Two clients read the same tag. One updates successfully with If-Match, and the other attempts a different update with the stale tag. I expect 412 for the stale writer and reconcile final state and events to prove no overwrite occurred.
Why should tests not calculate the expected ETag?
The value is opaque and its generation algorithm is an implementation detail. Calculating it makes tests brittle and can miss behavior errors. I test syntax, comparison, change behavior, and representation consistency instead.
What comparison does If-None-Match use?
It uses weak comparison, so weak and strong forms with the same opaque tag can match for exclusion. That behavior differs from If-Match, which requires strong comparison.
What does If-None-Match star mean?
It matches any current representation. On a state-changing create request, it can implement create only if absent, with 412 when the resource already exists. On GET for an existing resource, it causes 304.
How do you test ETags behind a CDN?
I compare origin and client-visible headers, exercise revalidation through the edge, vary supported representation dimensions, and test privileged then ordinary clients. I verify transformations do not leave an invalid strong validator or cross-user cache leak.
What happens when If-Match and If-Unmodified-Since are both sent?
A recipient that supports If-Match evaluates it first and ignores If-Unmodified-Since when If-Match is present. I create conflicting validators to confirm the implementation follows the defined precedence.
What evidence belongs in an ETag defect report?
I include the unconditional response and tag, exact conditional request, intervening mutation, conditional result, final state, and edge path. Raw headers, timestamps, correlation IDs, and instance or replica information help locate the failing layer.
Frequently Asked Questions
What should an ETag look like?
An entity tag is an opaque quoted value, optionally prefixed by `W/` for a weak validator. Clients and tests should preserve it exactly as received rather than parsing or regenerating it.
What status should If-None-Match return?
For GET or HEAD, a matching condition returns 304 and no body. For other methods, a false If-None-Match condition normally returns 412. A non-matching condition allows normal method processing.
What status should stale If-Match return?
A false If-Match precondition normally returns 412 Precondition Failed and the method must not be applied. Tests should verify final resource state and side effects, not only the status.
Can a weak ETag be used with If-Match?
It can be sent, but it cannot satisfy the required strong comparison. Even matching opaque text in weak and strong forms does not create a strong match.
Should an ETag change after every database update?
Not necessarily. It should reflect the selected representation according to the server's validator semantics. A meaningful change to a strongly validated representation must not retain a falsely matching strong tag.
How do ETags interact with content compression?
Encoded and unencoded bytes may need distinct strong validators, or a server may use an appropriate weak strategy. Test the client-visible representation through the actual compression layer and verify `Vary` and cache behavior.
Is 412 the same as 409?
No. A 412 response indicates that a request precondition evaluated false. A 409 response generally represents a conflict with current resource state that is not necessarily expressed through a conditional header.