Optimizing your Rails API

I have been working with API's for quite a long time and today I would like to share some benchmarking I've been doing an effort to optimize your Rails API.

I will walk you through several optimizations which you can do right away on your project or even if you are starting a new one.

Initial setup

The initial setup we will use, will be:

  • Rails 4.2
  • PostgreSQL
  • Puma
  • Ruby 2.2.X

We will render 25 user records with 5 attributes per record, id, name, email, created_at and updated_at

Setup

The puma configuration I used is:

workers Integer(ENV['WEB_CONCURRENCY'] || 2)  
threads_count = Integer(ENV['MAX_THREADS'] || 5)  
threads threads_count, threads_count

preload_app!

rackup      DefaultRackup  
port        ENV['PORT']     || 3000  
environment ENV['RACK_ENV'] || 'development'

on_worker_boot do  
  # Worker specific setup for Rails 4.1+
  # See: https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#on-worker-boot
  ActiveRecord::Base.establish_connection
end  

The users controller may look like this:

class Api::V1::UsersController < ApplicationController

  def index
    render json: User.all
  end
end  

We will benchmark the app using the Apache Benchmark tool. If you are on a mac you are ready to go.

% ab -c 5 -n 10000 http://127.0.0.1:5000/api/users

The results were:

Concurrency Level:      5  
Time taken for tests:   70.081 seconds  
Complete requests:      10000  
Failed requests:        0  
Total transferred:      40930000 bytes  
HTML transferred:       37630000 bytes  
Requests per second:    142.69 [#/sec] (mean)  
Time per request:       35.040 [ms] (mean)  
Time per request:       7.008 [ms] (mean, across all concurrent requests)  
Transfer rate:          570.35 [Kbytes/sec] received  

The important thing in here is how many requests per second the server can process, which are ~143. Not bad to start with, but let's get our hands dirty. By the end of the reading you will process a bit more than 1000 requests per second, so stick with me.

ActiveModelSerializers

The only thing in here we are going to change is that we will use ActiveModelSerializers to process the json output.

gem "active_model_serializers", github: "rails-api/active_model_serializers", branch: "0-8-stable"  

If we run the tests again, we can see a tiny improvement:

Concurrency Level:      5  
Time taken for tests:   69.314 seconds  
Complete requests:      10000  
Failed requests:        0  
Total transferred:      40930000 bytes  
HTML transferred:       37630000 bytes  
Requests per second:    144.27 [#/sec] (mean)  
Time per request:       34.657 [ms] (mean)  
Time per request:       6.931 [ms] (mean, across all concurrent requests)  
Transfer rate:          576.66 [Kbytes/sec] received  

As we can see, the increase was just about 2 requests.

Oj + rails-api

Oj is currently the fastest json parser out there, so lets check that out:

Gemfile:

gem "oj"  
gem "oj_mimic_json"  

Also it would be a good idea to check out the rails-api gem.

Gemfile:

gem 'rails-api'  

We run the bundle command and before we run the tests again, we have to change the users_controller a bit:

class Api::V1::UsersController < ActionController::API

  def index
    render json: User.all
  end
end  

We can run the test now:

Concurrency Level:      5  
Time taken for tests:   67.670 seconds  
Complete requests:      10000  
Failed requests:        0  
Total transferred:      40930000 bytes  
HTML transferred:       37630000 bytes  
Requests per second:    147.78 [#/sec] (mean)  
Time per request:       33.835 [ms] (mean)  
Time per request:       6.767 [ms] (mean, across all concurrent requests)  
Transfer rate:          590.67 [Kbytes/sec] received  

We are still increasing the performance, but it is not good enough, yet.

Metal

ActionController::Metal is the simplest possible controller, providing a valid Rack interface without the additional niceties provided by ActionController::Base.

We will use Metal which should be a good boost:

class Api::V1::UsersController < ActionController::Metal  
  include AbstractController::Callbacks
  include AbstractController::Rendering
  include ActionController::Renderers::All
  include ActionController::UrlFor
  include Rails.application.routes.url_helpers
  include ActionController::Serialization
  include ActionController::RackDelegation
  include ActionController::StrongParameters

  def index
    render json: User.all
  end
end  

We run the tests again and:

Concurrency Level:      5  
Time taken for tests:   61.438 seconds  
Complete requests:      10000  
Failed requests:        0  
Total transferred:      40930000 bytes  
HTML transferred:       37630000 bytes  
Requests per second:    162.76 [#/sec] (mean)  
Time per request:       30.719 [ms] (mean)  
Time per request:       6.144 [ms] (mean, across all concurrent requests)  
Transfer rate:          650.58 [Kbytes/sec] received  

As you can see we received a nice boost, almost 20 requests more per second.

Caching

The last thing to add, and this is where things are going to get interesting is to add caching with a 5 minutes time to live flag.

First of all we will add a concern under the controllers directory that look like this:

module Api  
  CACHE_EXPIRY = 3600*5
  # Rails cache store if production?
  def cache_storage(key=nil, &block)
    key = key.join('.') if key.is_a?(Array)
    result = unless Rails.env.development?
      Rails.cache.fetch key, timeToLive: CACHE_EXPIRY do
        yield if block_given?
      end
    else
      yield if block_given?
    end
    return result
  end
end  

Credits go to zenbakiak

And on the users_controller we just need to add that concern:

class Api::V1::UsersController < ActionController::Metal  
  include AbstractController::Callbacks
  include AbstractController::Rendering
  include ActionController::Renderers::All
  include ActionController::UrlFor
  include Rails.application.routes.url_helpers
  include ActionController::Serialization
  include ActionController::RackDelegation
  include ActionController::StrongParameters
  include Api

  def index
    render json: User.all
  end
end  

And last thing, but the most important one is to actually cache the response for the index action:

  def index
    json = cache_storage ["v1", "users"] do
      @users = User.all

      ActiveModel::Serializer.build_json(self, @users, {}).to_json
    end
    render json: json
  end

This will cache the response for 5 minutes and you can use the same structure on other actions or controllers.

Remember to restart the server and run under production environment in order for the cache to work.

If we run the benchmark again, we get a super boost:

Concurrency Level:      5  
Time taken for tests:   8.992 seconds  
Complete requests:      10000  
Failed requests:        0  
Total transferred:      34570000 bytes  
HTML transferred:       31270000 bytes  
Requests per second:    1112.06 [#/sec] (mean)  
Time per request:       4.496 [ms] (mean)  
Time per request:       0.899 [ms] (mean, across all concurrent requests)  
Transfer rate:          3754.30 [Kbytes/sec] received  

More than a thousand requests per second, which is an improvement of almost 10x since we started.

Just before we are done, you may want to include other modules such as ActionController::HttpAuthentication::Token::ControllerMethods to handle authentication.

Conclusion

Another nice addition when using arrays/collection would be to paginate them and render a bit less records, which may improve the responses per second.

Also keep in mind there are other caching tools out there, such as Memcache, Dalli and so on.

Happy API'ing.