Fast Server, Slow Browser

Here’s the situation: Your amazing developer team has rocked your world with caching and other optimizations to get data from your client’s Rails server over to the users’ browsers. Your monitoring shows requests that were taking a whole 80-90 seconds to serve are now only taking 1500ms (and all that time is actually spent transferring megabytes of html to the browser, actual data retrieval is ~10ms). These numbers come from a recent project I did for a client with production data. Now, to tackle the last piece of speeding it up… Chrome browser is taking MINUTES (6 minutes, actually) to fully build and render the page after it’s received the data. 100% unacceptable. Time to drop in some asynchronous data transferral and a specialized Javascript library to keep things performant on the client side!

Diving In

My last two posts have been about dramatically speeding up how you serve large, complicated data sets to your users. As described above, serving the data was now quick, but rendering the data in the browser took a full 6 minutes; I timed it, repeatedly. For reference, this was done on a current generation MBP, so the CPU is no slouch (i7 4770HQ @ 2.2ghz). The issue here is that multiple MB of HTML were being served up in a report to the browser as a DataTable. I detailed the caching process in my earlier post, but the gist is that each <tr>...</tr> worth of data is saved in a solr cache for each individual row of the report. These rows are styled, with each individual <td>...</td> also having styling. That adds a significant amount of processing time to display data to the user.

Clusterize.js

Clusterize solves this exact problem. Check out the demo on their page to see it in action. The basic mechanism is that it takes an array of data (as table rows, each row is one element) and only renders the ones you can see at any given time. Absolutely perfect for our needs, since no matter how big a user’s screen is, they’ll only be displaying a tiny fraction, 0.1% absolute max, of the report at a given time. Perfect for our needs.

The Solution

Setting up our app to use clusterize will involve the following steps:

  • Put the clusterize js and css assets in the app
  • Create a model to fetch the data from solr
  • Set up a data serving route and controller to return a JSON array of info
  • Point clusterize at our endpoint
  • Apply needed clusterize classes and ids to the table to make it display properly

Assets

Grab the two following files and throw them in your css and javascripts directories, respectively:

https://github.com/NeXTs/Clusterize.js/blob/master/clusterize.min.js
https://github.com/NeXTs/Clusterize.js/blob/master/clusterize.css

Don’t forget to add them to your precompile list in initializers/assets.rb if needed.

Solr data retrieval

This model exists solely to retrieve the prerendered contract data as an array from solr given a solr search on Contract.

# app/models/admin/reports/contracts.rb
class Admin::Reports::Contracts
  def contract_data(solr_search)
   contract_rows = []
   solr_search.hits.each do |hit|
     contract_rows << hit.stored(:mar_partial)
   end
   contract_rows
  end
end

The Controller and Routes to Serve JSON

# config/routes.rb

namespace :admin do
  namespace :reports do
    resources :contracts, only: [:index]
  end
end
# app/controllers/admin/reports/contracts_controller.rb

class Admin::Reports::ContractsController < Admin::BaseController
  def index
    search = DealJacket.solr_search do
      with(:contract_status, params[:contract_status]) if params[:contract_status].present? #If no filter, include all
      paginate(per_page: Contract.count)
    end

    render json: Admin::Reports::Contracts.new.contract_data(search)
  end
end

This simply takes a search param to scope down contracts (if provided) and renders the json data out. Try visiting the route in your browser, if you see a screen full of raw json text, it’s working as planned.

Coffeescript and Views

# app/assets/javascripts/contracts_report.js.coffee

build_clusterize = ->
  return new Clusterize({
        rows: window.contract_data,
        scrollId: 'scrollArea',
        contentId: 'contentArea'
  });

get_json = (url) ->
  $.getJSON url

$ ->
  $.getJSON(
    "/admin/reports/contracts",
    (data) ->
      window.contract_data = data["contracts"]
      $.clusterize = build_clusterize(window.contract_data)
  )

Coffee/JS is definitely my week point, so I’m sure this code can be improved, but it works great. What this does is the following:

  • Asynchronously gets json data from our Reports::ContractsController
  • Builds Clusterize with the provided array found in the JSON["contracts"]
  • Due to weird CoffeeScript variable scoping, data is attached to window for later usage
= content_for :footer_js do
  =javascript_include_tag "asset_registers"
  =javascript_include_tag "clusterize.min"
  =stylesheet_link_tag "clusterize", media: "all"

.clusterize
  table
    thead
      tr
        th Contract ID
        ...
        th Balance
  #scrollArea.clusterize-scroll
    table
      tbody#contentArea.clusterize-content
        tr.clusterize-no-data
          td Loading data...

The main points here are the following:

  • Encompass your whole table in the .clusterize div
  • Give your table rows their own table (makes the header row act as a sticky header)
  • tag tbody with #contentArea.clusterize-content, this tells Clusterize where to manage the displayed rows
  • Provide a data loading message to placate users while the JSON is fetched

End Result

All together, this loads the page with static table info (that “Loading Data…” row), and pulls your table rows into a new Clusterize object. As I mentioned, before clusterize, start to finish on a fast computer it took at least 6 minutes (that’s 360 seconds) to load and render the data so that it was usable. Now, it’s done consistently under 2 seconds, every single time. Hooking this all together made displaying a backend optimized report 180x faster.

I’ll call that a good accomplishment for the day.