DEV Community

Cover image for Stop sending entire objects in your PATCH requests. There's a better way
99Tools
99Tools

Posted on

Stop sending entire objects in your PATCH requests. There's a better way

I'll be honest — I spent years writing PATCH endpoints that just took the whole object and merged it server-side. You probably have too. It works. Until it doesn't.

You get concurrent edits. You get race conditions. You get a client that sends { "role": null } because it forgot to exclude the field. And your "partial update" is silently nuking data.

Then I found RFC 6902 — JSON Patch. And it solved exactly this problem in a way I hadn't thought about.

What JSON Patch actually is

Instead of sending the new version of an object, you send a description of the changes. A list of operations: what to add, remove, replace, move.

Here's the difference in practice:

❌ The old way — send the whole thing

PATCH /users/42
Content-Type: application/json

{
  "name": "Alice",
  "email": "alice@example.com",
  "role": "admin",
  "preferences": {
    "theme": "dark",
    "notifications": true
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ With JSON Patch — send only what changed

PATCH /users/42
Content-Type: application/json-patch+json

[
  { "op": "replace", "path": "/role", "value": "admin" },
  { "op": "replace", "path": "/preferences/theme", "value": "dark" }
]
Enter fullscreen mode Exit fullscreen mode

Two fields changed. Two operations. Nothing else touched.

The six operations

[
  { "op": "add",     "path": "/tags/0",    "value": "urgent"  },
  { "op": "remove",  "path": "/draft"                         },
  { "op": "replace", "path": "/status",    "value": "active"  },
  { "op": "move",    "from": "/tmp/name",  "path": "/name"    },
  { "op": "copy",    "from": "/template",  "path": "/doc"     },
  { "op": "test",    "path": "/version",   "value": 3         }
]
Enter fullscreen mode Exit fullscreen mode

The test op is the one most people ignore. It fails the whole patch if the value at the path doesn't match — which gives you optimistic locking for free. If /version isn't 3, nothing gets applied. That's huge for concurrent edits.

A real-world scenario

Imagine a collaborative config editor. Two users open the same document. User A changes the timeout. User B changes the retry count. With a full-object PUT, whoever saves last wins and overwrites the other's change.

With JSON Patch + test:

[
  { "op": "test",    "path": "/version",  "value": 7    },
  { "op": "replace", "path": "/timeout",  "value": 5000 }
]
Enter fullscreen mode Exit fullscreen mode

If someone else already committed and bumped the version to 8, this patch fails with a 409. The client knows to re-fetch and retry. Clean, explicit, auditable.

The diff problem — and how I handle it

The annoying part is writing patches by hand. When you're prototyping or debugging, you just want to paste two JSON states and see what changed.

The compare() output is exactly what a JSON Patch generator produces — a minimal array of ops describing the diff between two states. In production, you generate this programmatically. During prototyping, you're usually diffing by hand to understand what your code should be producing.

For production use, you don't write patches manually — you generate them programmatically from a diff.

Libraries worth knowing

For JavaScript/Node.js, fast-json-patch is the go-to:

import * as jsonpatch from 'fast-json-patch';

const before = { name: "Alice", role: "user" };
const after  = { name: "Alice", role: "admin" };

const patch = jsonpatch.compare(before, after);
// [{ op: "replace", path: "/role", value: "admin" }]

const result = jsonpatch.applyPatch(before, patch).newDocument;
Enter fullscreen mode Exit fullscreen mode

For Python, jsonpatch works the same way — make_patch() to diff, apply() to execute.

Things that will bite you

  • JSON Pointer paths (RFC 6901) use / as separator. A key containing a slash or tilde must be escaped: ~~0, /~1. This silently fails if you forget.
  • Array indices are fragile. /items/2 means the third element right now — if the array shifts between diff and apply, you're patching the wrong element. - as index means "append", which is usually safer.
  • Skip the test op and you lose the main concurrency benefit. It's optional in the spec, but you probably want it in any multi-user scenario.

When should you actually use this?

Honestly, not everything needs JSON Patch. If you're building a simple CRUD app with a single user, PATCH with a partial object is fine. JSON Patch earns its complexity when you have: concurrent editors, audit logs that need to record exactly what changed, sync protocols (think CRDTs, OT), or large objects where sending the whole thing is wasteful.

If any of those apply — it's worth the RFC read.


Are you using JSON Patch in production? I'm curious how people handle the array index fragility problem specifically — do you use stable IDs in your patch paths, or do you diff differently? Drop it in the comments 👇

Top comments (0)