Back to Field Notes

Why We Adopted JSON Patch (RFC 6902) And Why We Left It Behind

Every backend eventually hits the same wall: clients need to update part of a resource, but sending the whole object back and forth feels wrong. We tried JSON Patch. Here's what we learned.

TL;DRJSON Patch (RFC 6902) is a solid standard for partial updates, but if your primary consumer is a typed SPA built on OpenAPI -> codegen, it fights your toolchain. The path/value semantics aren't easily representable in typed schemas, leading to poor DX. We moved to typed partial DTOs with 'assigned properties' tracking - losing surgical array ops but gaining clean schemas, great codegen, and happier frontend devs.

We put our PATCH endpoints on JSON Patch, the IETF standard, ran with it across many endpoints and several sprints, and then took it back out. Reversing that decision was the right call, and the reasons had almost nothing to do with the spec itself.

We were building PATCH endpoints for complex domain entities with DTOs that had 20+ fields, nested objects and a couple of lists. The frontend was building forms and most edits were small: update a description, flip a boolean, adjust a timestamp.

Asking the SPA to send the full DTO just to change one field meant every request carried fields nobody had touched, and one stale field could overwrite newer server state. It also put correctness on the client, where the backend cannot enforce it.

We already had a homegrown pattern (TrackableObject<T>) where the client could signal which fields were intentionally sent. It worked, but it was custom, hard to explain to newcomers and it didn't cover array operations.

So we went looking for a standard.

Enter JSON Patch (RFC 6902)

JSON Patch is an IETF standard: you send an array of operations like replace, add, remove, etc., each targeting a location using JSON Pointer paths.

[
  { "op": "replace", "path": "/description", "value": "Updated text" },
  { "op": "add", "path": "/tags/-", "value": "urgent" }
]

On paper, it's elegant. You get surgical precision (including arrays) and even a test op that can act like an in-band "only apply if unchanged" guard.

We used a System.Text.Json-friendly JSON Patch library (to avoid pulling Newtonsoft into our gateway).

What worked well (for the backend)

The gateway-applies-patch pattern

We kept JSON Patch logic out of our microservices. The BFF/gateway was the only layer that understood patch:

  1. Frontend sends PATCH to the gateway
  2. Gateway GETs current state from the downstream service
  3. Gateway applies patch locally
  4. Gateway PUTs the full updated DTO downstream

That part was clean. Downstream services never had to learn what a patch document was.

Standards compliance

Pointing people at an RFC beats explaining custom semantics every time.

Where reality hit: the frontend and the toolchain

The surprise was that none of our problems turned out to be with the protocol.

1. OpenAPI can't express "path -> value type" in a helpful way

Our workflow was spec-driven:

  • Backend auto-generates OpenAPI
  • Frontend uses Orval to generate TypeScript clients/hooks

That pipeline is excellent when your request body is a typed object.

But JSON Patch is "an array of operations," and the meaningful bits live inside:

  • path: string (semantic meaning hidden in a string)
  • value: anything (type depends on the path and operation)

So OpenAPI typically ends up describing JSON Patch as generic operations (op, path, value) where value is basically "any." Orval is not failing here. It is faithfully generating what the spec is able to describe.

Yes, you can try to model it with oneOf/compositions, but it quickly becomes a combinatorial mess once you have dozens of fields and multiple operations.

2. Our auto-generated OpenAPI needed post-processing

We ended up adding a transformer step to "fix up" the OpenAPI around patch payloads so our client generation wouldn't degrade too badly.

JSON Patch and Swagger-style tooling do not get along, and the usual fixes are all variations on the same idea: a custom schema filter, or a hand-written operation model you substitute for the generated one. Whichever you pick, you now own a piece of code that rewrites your own generated spec, and it has to keep working every time the spec changes.

3. The frontend experience felt like busywork

The most consistent feedback from frontend was simple: this doesn't feel like form data.

They wanted to send:

{ "description": "Updated text" }

Instead they had to construct:

[{ "op": "replace", "path": "/description", "value": "Updated text" }]

Then came the follow-up pain:

  • Nested paths (JSON Pointer uses /, escaping rules, special - index, etc.)
  • Nullable semantics (remove vs replace with null)
  • "What paths are valid?" (not really discoverable through generated types)

None of this is hard once you know it. The issue is the steady drip of friction across many endpoints and many sprints.

4. The extra round-trip wasn't free

Our gateway pattern required a GET before applying the patch, which turned every PATCH into "GET + apply + PUT." For small edits, it felt disproportionate.

And the cost was the smaller half of the problem. GET-apply-PUT is a read-modify-write: two PATCHes arriving together both read the same snapshot, each applies its own operations to that copy, and whichever PUT lands second overwrites the first. That is the same lost update we set out to avoid, rebuilt one layer higher.

RFC 6902 does ship a guard for exactly this. A test op at the head of a patch makes the whole document conditional: if the test fails, none of the following operations are applied. The catch is where the tested value comes from. It has to be the value the client read when it loaded the form, not something the gateway fills in from its own fresh GET, or the test compares the snapshot against itself and guards nothing. Get that right and the round-trip earns its keep.

If you adopt the gateway pattern, pick your concurrency guard deliberately, whether that is an ETag with If-Match, a row version carried on the entity, or a leading test op. The pattern does not hand you one.

5. Testing surface area exploded

Now you're testing ordered operation sequences, invalid paths, array bounds and edge cases around test + subsequent ops. Compared to "send a partial typed object," the matrix grows fast.

What we moved to: typed partial DTO + "assigned properties"

We had a working, standards-compliant implementation and we pulled it out. That was the harder call and the right one. What we went back to is the boring approach, chosen this time on purpose rather than by default.

Frontend sends a normal typed JSON object:

{ "description": "Updated text", "isComplete": true }

Backend tracks which properties were actually present in the request (think "assigned properties") and only updates those. Missing fields remain untouched.

The OpenAPI schema went back to being a plain object, which meant Orval generated a real client again: autocomplete on field names, and a compile error when one of them changed. Form code stopped building documents and went back to sending its own state.

What we gave up was surgical array operations and the test op. In practice we almost never needed "insert item at index 0"; our real use cases were "replace the whole list." Concurrency we handled outside the patch document.

What we'd tell the next team

JSON Patch is a solid standard, and we still like the gateway boundary it pushed us into. What we misjudged was the size of the decision. Choosing the protocol also chose the OpenAPI schema our frontend would be handed and the shape of every form built on top of it, and we only really evaluated the protocol.

The capability we were paying for, ordered and surgical array edits, was one our product never asked for. The bill (untyped generated clients, plus a schema transformer to keep maintaining) arrived on every endpoint and was mostly paid by people who were not us. If your primary consumer is a typed SPA generated from your spec, that is the trade to put on the table first, before anyone opens the RFC.

Resources


Timothy De Bock

Timothy De Bock

Full-stack .NET platform engineer specializing in government, healthcare & security sectors.