JSON Patch in Production: Atomicity, Idempotency, and Concurrent Conflicts
Bottom line: JSON Patch is only a change format; atomicity, idempotency and concurrency are still your job. Covers test preconditions, path escaping, rollback and ETag coordination.

Bottom line: validate the patch's target paths and permissions first, then add a concurrency assertion with test or If-Match; apply the patch to a copy so a failure leaves nothing half-written.
The real danger of a
PATCHrequest is not writing the wrong field. It is believing the write succeeded while silently overwriting someone else's change. JSON Patch gives you the tools, but the safety design is still yours.
1. A Quick Review of the RFC 6902 Operation Set
JSON Patch describes changes as an array of operations, with the media type application/json-patch+json. Six operations execute in array order:
| Operation | Effect | Key constraint |
|---|---|---|
add |
Add a member or insert an array element | An array index may equal the length; - means the end |
remove |
Remove a member or element | The target path must exist |
replace |
Replace an existing value | The target path must exist |
move |
Move a node | Cannot move a node into its own child path |
copy |
Copy a node | The source path must exist |
test |
Assert value equality | If it fails, the whole patch does not apply |
Many people remember only the first three and forget that test is what turns a blind write into a conditional write. The full operation semantics and array boundaries are covered in JSON Pointer, Patch, and Merge Patch.
2. Using test to Turn a Patch into a Conditional Update
A typical optimistic-locking patch starts not with a write, but with a version assertion:
{"op":"test","path":"/version","value":1}
Put it into a complete patch:
[
{ "op": "test", "path": "/version", "value": 1 },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "replace", "path": "/status", "value": "paid" }
]
The logic is: confirm that the document version is currently 1, then change it to 2. If another request has already advanced the version to 2, the first test fails, the whole patch does not apply, and the server returns a conflict. This prevents lost updates without introducing a distributed lock.
A few design points:
- Put
testbefore every write. If an earlier write has already taken effect, the assertion is meaningless; - Assert on a version number or digest generated by the server, not on an ordinary field the client can freely change;
- Increment the version on every successful write; otherwise two requests using the same old version both pass;
- The
valueoftestmust have exactly the same JSON type as the document value.1and"1"are not equal.
3. Path Escaping and Prototype Pollution
JSON Pointer escaping rules are simple: write ~ as ~0 and / as ~1, and replace ~ before /, otherwise you get double escaping.
Key "a/b" → /a~1b
Key "m~n" → /m~0n
Key "a~/b" → /a~0~1b
The risk with external patches is usually not escaping but dangerous keys. In JavaScript, keys such as __proto__, constructor, and prototype can trigger prototype pollution, even through an ordinary add:
[
{ "op": "add", "path": "/__proto__/polluted", "value": true }
]
Use an allowlist rather than a denylist:
- Permit patches to modify only business fields, such as
/statusor/items/0/quantity; - Validate every
pathandfromagainst a regular expression or path prefix, rejecting unexpected levels other than array indices; - Parse JSON into
Object.create(null)orMapcontainers to reduce the impact of a polluted prototype chain; - Cap the number of operations and the byte size of each
value.
4. Atomic Application and Rollback
RFC 6902 requires atomic execution, but atomicity is a semantic requirement, not a default in every library. Libraries such as fast-json-patch mutate the document in place when mutateDocument is true, and a failure may leave partial changes behind.
import { applyPatch, validate } from 'fast-json-patch';
/**
* Apply a patch immutably and return the original document on failure.
* @param document The original document, which callers must not mutate
* @param patch The JSON Patch operation array to apply
* @returns The result, including a success flag and the new document
*/
function applySafely(document: unknown, patch: unknown[]) {
const errors = validate(patch, document);
if (errors) {
return { ok: false, document };
}
try {
const result = applyPatch(document, patch, false, false);
return { ok: true, document: result.newDocument };
} catch {
return { ok: false, document };
}
}
Three implementation recommendations:
- Deep copy or structured-clone first, apply on the copy, and swap the reference only after success;
- Do not rely on a library's automatic rollback; behavior on exceptions differs across implementations;
- Validate before executing:
validatechecks structure only, while semantic conflicts still requiretest; neither replaces the other.
If a patch spans multiple resources, such as changing an order and its inventory together, JSON Patch atomicity holds only within a single document. Across documents you need a database transaction or compensating actions.
5. Idempotency: PATCH Is Not Naturally Idempotent
HTTP semantics define PUT and DELETE as idempotent, but PATCH is not necessarily so. Repeating the same JSON Patch can produce completely different results:
add /items/-appends to the end of the array every time, so retries create duplicates;replace /versionchanges version 1 to 2 and succeeds again on a retry, even though the operation has already been applied;remove /tempfails on the second run because the path no longer exists.
There are three common ways to achieve idempotency:
| Approach | How it works | Best for |
|---|---|---|
| Version precondition | First operation is test /version, then increment |
Resources with an explicit version |
| Idempotency key | Client sends Idempotency-Key; server caches the result |
Payments, orders, and other writes |
| Target-state patch | Use replace instead of add, so the result depends only on the final value |
Configuration and status fields |
The worst pattern is a patch that mixes add and remove while assuming retries are safe. If a patch appends or deletes anything, it must be paired with a version assertion or an idempotency key.
6. Concurrent Conflicts: ETag and If-Match
Besides test inside the patch, HTTP offers a standard mechanism: the server returns an ETag, and the client sends If-Match on the next request.
GET /orders/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json
[{"op":"replace","path":"/status","value":"paid"}]
If the resource has changed, the server returns 412 Precondition Failed, and the client refetches before retrying. If-Match: * means "execute as long as the resource exists", which is not suitable when you need to prevent overwrites.
The difference between test and If-Match:
If-Matchis checked at the HTTP layer before body parsing, so it is cheaper;testis checked at the patch semantic layer and can assert any field without depending on server-side ETag support;- They can be combined, but do not implement only one of them and claim the endpoint is safe under concurrency.
7. Library Differences and Selection
| Implementation | Language | Atomicity | test support |
Notes |
|---|---|---|---|---|
fast-json-patch |
JavaScript | Optional (returns a new document by default) | Yes | Broad ecosystem; watch mutateDocument |
jsonpatch |
Python | Rolls back on exceptions | Yes | Close to the standard, with clear errors |
json-patch |
Go | Operates on a copy | Yes | Often used alongside JSON Merge Patch |
zjsonpatch |
Java | Yes | Yes | Convenient Jackson integration |
Do not select a library solely on "supports all six operations". Confirm three things: whether a failure rolls back, whether test compares strictly, and whether an out-of-range array add raises an error. Loose implementations make the same patch behave differently across environments.
8. A Pre-Launch Safety Checklist
- [ ] The endpoint declares
application/json-patch+json; - [ ] Patches are applied to a copy, and a failure never contaminates the original document;
- [ ] Every
pathandfromin external patches passes an allowlist check; - [ ] Dangerous keys such as
__proto__,constructor, andprototypeare rejected; - [ ] Operation count and single-value size are capped;
- [ ]
testorIf-Matchprovides concurrency control; - [ ] Patches are written to an audit log with actor, timestamp, and the full operation array;
- [ ] Transaction boundaries are explicit when a patch spans documents.
A patch is inherently a complete description of "what changed", which makes it a natural audit record. For error codes and idempotency at the API layer, continue with JSON API Design. If a patch comes from merging two branches, you also need conflict semantics, covered in Git Merging for JSON Files.
FAQ
What status code should a failed test operation return?
If the conflict comes from a version assertion, both 409 Conflict and 412 Precondition Failed are defensible. Prefer 412 to stay consistent with If-Match, and include the current version in the response body so the client can retry.
Can a patch containing add and remove be retried safely?
Not directly. add to the end of an array duplicates data, and remove fails on the second run because the path is gone. Add a version precondition or an idempotency key so retries are recognized as the same operation.
Can JSON Patch guarantee cross-document transactions?
No. RFC 6902 atomicity applies to a single JSON document. When orders, inventory, or other resources are involved, use database transactions, Saga compensation, or a message queue for consistency.
Why must a patch never write to __proto__?
Because most JavaScript implementations store the document in ordinary objects, writing __proto__ can change the prototype chain and affect every object afterward. A path allowlist is the most reliable defense; do not rely only on a library's internal filtering.
How do I make a PATCH request idempotent?
There are three common approaches: start the patch with test /version and increment the version on success, have the server cache the result for an Idempotency-Key, or use replace instead of add so the result depends only on the final value. If a patch appends or deletes anything, it must be paired with a version assertion or an idempotency key; it cannot simply be retried.
Should I use both test and If-Match?
You do not have to use both, but implementing only one of them does not make an endpoint safe under concurrency. If-Match is checked at the HTTP layer before body parsing, so it is cheaper; test is checked at the patch semantic layer and can assert any field without depending on server-side ETag support.