Correction, October 2026: The examples below fix validation-error handling, avoid partial in-memory changes when a patch fails, and handle root replacement. The concurrency discussion has also been corrected. This remains a focused example rather than a complete API implementation.
DocSpring is a service that generates PDFs. The backend is written with Ruby on Rails, and the PDF template editor is built using React. We use Postgres, and store the template’s field data in a jsonb column.
The first version of our template editor used a naïve approach when saving field data. Whenever the fields changed, we would just post the entire array to the server. This was working fine for our MVP and initial launch, but then a customer started setting up a PDF template with almost 500 fields. We started seeing alerts for requests that were taking around 5-10 seconds.
The obvious solution was to only send changes to the server. We use Redux in our React app, so I thought about sending the Redux actions. This wasn’t an option for DocSpring, because our Redux actions include some complicated logic. I didn’t want to rewrite that code in Ruby, but if we were using Node.js, then we might have been able to re-use the same JavaScript on the server.
The other option is to diff the plain JSON objects, and send the diff to the server. We can use JSON Patch for that:
JSON Patch is a format for describing changes to a JSON document. It can be used to avoid sending a whole document when only a part has changed.
I think it’s almost always better to use an IETF standard instead of inventing your own thing.
The first version of DocSpring used my own syntax for field names, until I discovered JSON Pointers. I hadn’t thought about escaping forward slash characters, but that is part of the specification.
I used the following open source libraries:
- fast-json-patch — An NPM package to generate a JSON patch on the client
- hana — A Ruby gem to apply the JSON patch on the Rails server
Here’s how I am applying patches to json columns in my Rails models:
class Template < ApplicationRecord
attr_accessor :json_patch_errors
attr_reader :fields_patch
validate :add_json_patch_errors
after_save :clear_json_patches
def fields_patch=(patch_data)
@fields_patch = patch_data
self.json_patch_errors ||= {}
json_patch_errors.delete(:fields)
unless patch_data.is_a?(Array)
json_patch_errors[:fields] = 'JSON patch data was not an array.'
return
end
valid_operations = patch_data.all? do |operation|
operation.is_a?(Hash) &&
%w[add remove replace move copy test].include?(operation['op']) &&
operation['path'].is_a?(String) &&
(!%w[add replace test].include?(operation['op']) || operation.key?('value')) &&
(!%w[move copy].include?(operation['op']) || operation['from'].is_a?(String))
end
unless valid_operations
json_patch_errors[:fields] = 'JSON patch contains an invalid operation.'
return
end
begin
# Hana mutates its input. Keep the model unchanged if any operation fails,
# and use the return value so replacing the document root also works.
self.fields = Hana::Patch.new(patch_data.deep_dup).apply(fields.deep_dup)
rescue Hana::Patch::Exception, Hana::Pointer::Exception, KeyError, IndexError, TypeError => ex
json_patch_errors[:fields] = "Could not apply JSON patch to fields: #{ex.message}"
end
end
def reload(*args, **kwargs)
super
clear_json_patches
self
end
private
def add_json_patch_errors
return if json_patch_errors.blank?
json_patch_errors.each do |attribute, message|
errors.add(attribute, message)
end
end
def clear_json_patches
@fields_patch = nil
self.json_patch_errors = nil
end
end
You could copy these RSpec tests to make sure your implementation is correct. We might also release this as a gem at some point.
I added fields_patch as a permitted parameter in the controller:
template_params = params.require(:template)
attributes = template_params.permit(fields: {}).to_h
if template_params.key?(:fields_patch)
# Extract only this attribute; the model validates its operations and values.
attributes['fields_patch'] = template_params.to_unsafe_h['fields_patch']
end
attributes
This means that :fields_patch is treated like a normal attribute, and the patch will be applied during update. If a patch fails to apply, an error will be added during validation.
This example only patches the fields value. The application must still authorize the update, validate the resulting field schema, and limit request size and operation count. Do not pass the entire to_unsafe_h result to mass assignment.
The front-end implementation was very easy. Our previous code looked like this:
if (!Immutable.is(template.fields, previousTemplate.fields)) {
data.fields = template.fields.toJS();
}
The new code sends a JSON patch as the fields_patch attribute.
import { compare as jsonPatchCompare } from "fast-json-patch";
if (!Immutable.is(template.fields, previousTemplate.fields)) {
data.fields_patch = jsonPatchCompare(
previousTemplate.fields.toJS(),
template.fields.toJS()
);
}
Here’s an example AJAX request from the new code:
{
"template": {
"fields_patch": [
{
"op": "replace",
"path": "/2/name",
"value": "image_field_2"
}
]
}
}This change was just a few lines of code, but now we are sending much less data.
JSON Patch reduces the data sent, but it does not make concurrent edits safe by itself. Operations run in order; array insertions and removals can shift later paths, and even replacements can overwrite another user’s change. Use a version check or optimistic locking to reject stale updates, then reload and reconcile them. The operation for inserting a value is add, not insert. WebSocket broadcasts can keep clients informed, but they do not replace conflict detection.
Thanks for reading! You can leave a comment on Hacker News.