Better Rails Performance with JSON Patch

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 thing1. 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
  # If there are any errors, we store them in here,
  # and add them during validation
  attr_accessor :json_patch_errors
  validate :add_json_patch_errors
  after_save :clear_json_patches

  attr_reader :fields_patch

  # The patch is applied as soon as this method is called.
  def fields_patch=(patch_data)
    # In case we want to access it later.
    @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

    hana_patch = Hana::Patch.new(patch_data)
    begin
      hana_patch.apply(fields)
    rescue Hana::Patch::Exception => ex
      json_patch_errors[:fields] =
        "Could not apply JSON patch to \"fields\": #{ex.message}"
    end
  end

  # Clear any JSON patches and errors when reloading data
  def reload
    super
    clear_json_patches
  end


  private

  def add_json_patch_errors
    return unless json_patch_errors.present?
    json_patch_errors.each do |attribute, errors|
      errors.add(attribute, errors)
    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:

params.require(:template).permit(
  fields: {},
).tap do |permitted|
  # Nested arrays and hashes are tricky.
  if params[:template][:fields_patch].is_a?(Array)
    permitted[:fields_patch] = params[:template][:fields_patch].
      map(&:permit!)
  end
end

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.

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.

Another advantage of JSON Patch is that it’s much easier to support concurrent editing with multiple users. It is often possible to apply patches in any order, especially when they only contain replace or insert operations. If there is a conflict, then you can just reload the latest data and let the user try again. You can also keep all the clients in sync by using websockets to send patches from the server to the browser.

Thanks for reading! You can leave a comment on Hacker News.