Quick answer: Run five JSON API concurrency tests before trusting conditional updates: verify validator scope, force competing writes at persistence, inspect every mutation path, reject validators from deleted resource incarnations, and preserve user intent during conflict recovery. An ETag header is evidence of a validator, not proof that the whole application prevents lost updates.
Two editors save valid JSON. Both see success. One person's change disappears. Syntax validation cannot catch this failure because the problem is when the document was read, not whether it parses.
An August 2, 2026 Vikunja feature request describes conditional reads without corresponding mutation support. Treat that as a reported integration concern, not a verified claim about every deployment. This checklist turns that concern into reproducible release tests. For general endpoint design, start with JSON in REST APIs.
1. Does the validator identify the exact editable state?
Capture the JSON document and validator from the same response, then prove that the validator changes whenever the editable representation changes. Keep tags opaque and quoted. Test projections and embedded data separately, because a list result or shortened representation must not be assumed to carry the detail endpoint's update precondition.
Imagine an order editor whose document contains a delivery address and a line-item collection. A tag derived only from the order table's timestamp misses a line-item update if that update does not advance the parent timestamp. The editor then holds an apparently current token for an obsolete document. Build a fixture that changes a child through its own route and attempts a parent save afterward.
MDN's ETag reference distinguishes strong representation validators from weak ones. The If-Match reference specifies strong comparison: a weak validator does not become usable by deleting its prefix. Store the returned header as a string, not an unquoted revision number.
This executable fixture demonstrates preservation, not a complete HTTP parser:
import assert from 'node:assert/strict';
const response = new Response('{"name":"Dock A"}', {
headers: { 'Content-Type': 'application/json', ETag: '"dock-a-r12"' }
});
const snapshot = {
body: await response.json(),
validator: response.headers.get('etag')
};
const outgoing = new Headers({ 'If-Match': snapshot.validator });
assert.equal(outgoing.get('if-match'), '"dock-a-r12"');
assert.equal(snapshot.body.name, 'Dock A');
Test the actual browser path too. Cross-origin JavaScript needs permission to read non-safelisted response headers; explicitly exposing ETag is covered by Access-Control-Expose-Headers. The request's If-Match header also needs appropriate preflight handling. A successful command-line request cannot establish browser compatibility; use the JSON API CORS checks for that boundary.
Keep one documented write representation where possible. If compact and expanded responses differ, define which endpoint supplies the write validator instead of teaching clients to manufacture tokens from whichever revision field they find. A cache validator and an application version field are not automatically interchangeable contracts.
2. Can two writers pass a check before either saves?
Pause two requests after they read the same version, release both toward persistence, and assert that only one conflicting update commits. Put the version predicate inside the protected write operation. A separate comparison followed by an unconditional update leaves a race even when sequential stale-request tests already pass reliably.
The important pause belongs near storage, not between two browser clicks. Sequential testing proves that yesterday's tag is rejected today; it does not prove that two requests cannot both observe the current tag before either commits. Add a test barrier or instrumented repository adapter so both requests reach the vulnerable boundary deterministically.
For a relational implementation, this SQL sketch puts the expected revision in the update predicate. It is illustrative PostgreSQL, not a claim that this database test was executed:
UPDATE documents
SET body = $1::jsonb, revision = revision + 1
WHERE id = $2 AND revision = $3
RETURNING revision;
PostgreSQL's UPDATE documentation explains the predicate and returned rows. Its Read Committed isolation documentation explains re-evaluation of an update condition after a competing updater commits. Integrate tenant and authorization scope as well; a revision predicate is not an access-control check. Map no-match outcomes according to the endpoint's existence and disclosure policy.
This in-memory model tests the intended compare-and-swap result. Its synchronous function is deliberately indivisible within this process; it does not demonstrate distributed database safety:
import assert from 'node:assert/strict';
let row = { revision: 12, name: 'Dock A' };
function save(expected, name) {
if (row.revision !== expected) return false;
row = { revision: row.revision + 1, name };
return true;
}
const expected = row.revision;
const results = await Promise.all([
Promise.resolve().then(() => save(expected, 'Dock B')),
Promise.resolve().then(() => save(expected, 'Dock C'))
]);
assert.equal(results.filter(Boolean).length, 1);
assert.equal(row.revision, 13);
assert.equal(row.name, 'Dock B');
Assert more than the HTTP statuses. Read back the document, revision, audit record, and queued business work. The rejected writer must not trigger a shipment notification before discovering that its save lost. If downstream work is asynchronous, stage it transactionally with the accepted change and publish it after commit using the application's established delivery mechanism.
Keep the test repeatable by controlling ordering rather than adding a random delay. Record when both requests have loaded their snapshots, release the first write, wait for its commit, then release the second protected operation. Add a second variant where both storage calls start together. The first variant establishes the stale-write expectation; the second exercises your storage adapter's actual contention behavior. Run against the same database engine and isolation configuration used for deployment, because an in-memory repository can hide locking and transaction differences.
3. Can another mutation path bypass the same protection?
Inventory every path that can change the protected document, then run a stale-write test through each relevant path. Cover deletion, bulk imports, administrative actions, background jobs, and child-resource changes. Define the missing-precondition policy explicitly; an optional browser header cannot enforce a system-wide guarantee when other writers ignore it entirely.
Create a small ownership table during review. Name the resource, each writer, where its expected version originates, and which operation advances the version. A scheduled reconciliation job might legitimately update current state without a human snapshot, but it still needs a deliberate atomic mutation strategy. It must not accidentally restore fields from an earlier export.
The status distinction matters. MDN describes 428 Precondition Required for a required conditional request, whereas a supplied but failed precondition normally leads to 412. RFC 9110's If-Match definition also makes the wildcard an existence condition, not an assertion that a specific previously read version is unchanged.
| Request case | Test expectation under a required-version contract |
|---|---|
| Missing precondition | Documented rejection, commonly 428 |
| Stale specific tag | Rejection without mutation, normally 412 |
| Current specific tag | Authorized, valid edit can commit |
| Weak tag | Must not satisfy strong comparison |
| Wildcard | Do not mistake existence for version equality |
Do not quietly reinterpret a valid wildcard as a specific old revision. If your narrower endpoint contract disallows it, document the restriction and its response. Similarly, production parsing must handle or deliberately reject supported header syntax; the earlier JavaScript fixture is not suitable as middleware.
Deletion deserves its own fixture: one client loads an order, another changes its destination, and the first submits a stale delete. Protecting only PUT leaves that decision exposed. For bulk requests, document whether conflicts reject the whole batch or produce per-item outcomes, then verify there is no undocumented partial success.
Roll out stricter requirements with client compatibility in mind. Count missing-condition traffic by route and client family without logging credentials or document bodies. An explicit migration policy is safer than claiming universal protection while retaining silent unconditional writes for unknown clients.
Make the inventory a review artifact owned by the resource team. When a new mobile endpoint or spreadsheet importer appears, its author should add a writer entry and the corresponding stale-state fixture. Shared persistence helpers reduce duplication, but test through public handlers too: an adapter can omit the expected revision or translate a conflict into success. Verify that every response preserves the caller's ability to distinguish accepted work from work that still needs attention.
4. Can an old tag match a deleted and recreated resource?
Delete a resource, recreate the same public identifier, and attempt an update using the original resource's validator. Reject the old edit when recreation represents a new logical object. Include an incarnation identifier or equivalent lifecycle distinction in your version design so resetting a local revision counter cannot authorize an obsolete request.
A resource called template-default starts at revision one, is deleted, then is created again at revision one. If the validator contains only that counter, an abandoned editor can appear current. This is an application lifecycle problem: the JSON can even look identical while the new object has a different owner, approval history, or intended lifetime.
The following fixture tests a token-design invariant. It assumes one stable serialization and uses fixed incarnation labels for a deterministic test; production identity generation and representation variation need their own design:
import assert from 'node:assert/strict';
const tag = (incarnation, revision) =>
`"${incarnation}-r${revision}"`;
const old = tag('object-a', 1);
const recreated = tag('object-b', 1);
assert.notEqual(old, recreated);
assert.equal(old === recreated, false);
Also decide what a no-op means. Rejecting every harmless intervening save is conservative, but generating a different validator for identical bytes can create unnecessary conflicts. Conversely, an application version may intentionally track workflow events beyond representation bytes. Keep the HTTP validator's guarantees accurate and expose a separate application precondition if the business contract needs more than representation identity.
For background, optimistic concurrency control validates assumptions before committing rather than holding a user-duration lock. That background does not define your endpoint's lifecycle semantics. Write those semantics down: whether public identifiers are reusable, whether restores preserve identity, and whether imported backups can rewind counters.
Add a restore fixture alongside recreation. Restore an old backup into a staging environment and inspect whether previously issued tokens could accidentally match. If that matters to your service, invalidate the relevant token namespace during restore rather than relying on the counter always increasing forever.
5. Does conflict recovery preserve intent instead of replaying stale JSON?
Retain the original snapshot, local edit, and newly fetched server document after a conflict. Compare all three before constructing a replacement request. Automatically refreshing the validator while replaying an old body defeats the protection. Require review for overlapping edits and submit only the reconciled candidate against the version actually reviewed.
Consider a delivery instruction with a contact name and arrival window. Alice changes the window; Bob changes the contact using a whole-document form loaded earlier. After Bob receives a conflict, copying the newest tag onto his old body would erase Alice's window. A successful retry can therefore be a data-loss bug disguised as convenient recovery.
This small fixture identifies field-level conflicts for a flat object. Nested structures, deletion, arrays, and domain relationships require a richer reconciliation policy:
import assert from 'node:assert/strict';
const base = { contact: 'Ana', window: '09:00' };
const local = { contact: 'Bo', window: '09:00' };
const remote = { contact: 'Ana', window: '10:00' };
const changed = (a, b) => Object.keys(a).filter(k => a[k] !== b[k]);
const mine = changed(base, local);
const theirs = changed(base, remote);
const overlaps = mine.filter(k => theirs.includes(k));
assert.deepEqual(overlaps, []);
const candidate = { ...remote };
for (const key of mine) candidate[key] = local[key];
assert.deepEqual(candidate, { contact: 'Bo', window: '10:00' });
Use JSON Compare to inspect sanitized snapshot differences during fixture review. It reports structural differences, not a safe three-way merge, and cannot verify HTTP preconditions. For collection replacement and omitted-field behavior, pair this checklist with the JSON Merge Patch endpoint tests.
Keep unsaved work visible when reload fails. Distinguish permission loss, deletion, offline state, and a genuine overlapping edit instead of showing one generic retry button. If a network timeout follows a save, do not assume failure; reconcile the outcome using the API's documented request identity or read-back strategy. Version checks and idempotent request handling solve different problems.
The four JavaScript fixtures here were executed locally. The SQL and endpoint scenarios are implementation guidance, not completed live-service tests. A release passes this checklist only when real writers, persistent state, browser headers, lifecycle events, and recovery behavior have been exercised together. Keep those assertions in regression tests so a new import route cannot quietly reopen the lost-update path.