Basic Rails API Caching

Rails performance out of the box is acceptable for prototypes and small applications with limited traffic. However as your application grows in popularity you will inevitably be faced with the decision to either add more servers, or use your existing servers more efficiently. Complex caching strategies can be incredibly difficult to implement correctly, but simple caching layers can go a long way.

In this post I’ll explain two basic Rails caching mechanisms and explain some of the costs and benefits of each.

HTTP Caching

If your API responses are mostly static content like a list of available products, then HTTP Caching can be a very effective solution. Even something as low as a one minute cache can move the vast majority of your requests to a CDN like CloudFlare or an in-memory store like rack cache.

Specifying the expiration time is simple. In your controller action just call expires_in with the time:

class ProductsController < ApplicationController
  def index
    expires_in 1.minute, public: true
    @products = Product.all
  end
end

This will result in a header being set to Cache-Control: max-age=60, public which any CDN will pick up and serve for you instead of the request hitting your server.

This solution works well when the content is mostly static but it comes with the downside that changes to your content will not be seen for up to one minute (or whichever time you have chosen).

Conditional GET

Another option is using ETags or Last-Modified times to know what version of the resource the client has last seen and returning a HTTP 304 Not Modified response with no content if the resource has not changed.

To set this up in a controller you can either use the fresh_when or stale? methods. Here is an example using the fresh_when method.

class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])
    fresh_when(etag: @product, last_modified: @product.created_at, public: true)
  end
end

This method attaches an ETag header and a Last-Modified header to every product response. Now if you make a request for a given product you will see the headers in the response:

curl -i localhost:3000/products/1.json
HTTP/1.1 200 OK
ETag: "91206795ac4c5cd1b02d8fcbc752b97a"
Last-Modified: Mon, 27 May 2014 09:00:00 GMT
...

And if you make the same request but include the ETag in a If-None-Match header, the server can return 304 with empty content and save all the time it would have spent rendering the content.

curl -i localhost:3000/products/1.json \
  --header 'If-None-Match: "91206795ac4c5cd1b02d8fcbc752b97a"'
HTTP/1.1 304 Not Modified
Etag: "91206795ac4c5cd1b02d8fcbc752b97a"
Last-Modified: Mon, 27 May 2014 09:00:00 GMT
...

The other option is to use the If-Modified-Since header in the request, which will have the same result:

curl -i localhost:3000/products/1.json \
  --header 'If-Modified-Since: Mon, 27 May 2014 09:00:00 GMT'
HTTP/1.1 304 Not Modified
Etag: "91206795ac4c5cd1b02d8fcbc752b97a"
Last-Modified: Mon, 27 May 2014 09:00:00 GMT
...

This method still requires a request to be made to the Rails app, and the product still has to be pulled from the database to determine the created_at time. However, rendering the response body can be a substantial portion of each server response so this is a simple way to save a lot of time.

These examples are only the beginning of the caching options Rails offers. As of Rails 4 page caching as well as action caching have been pulled out into their own gems and are worth looking at if you need those options.

Finally if you are ready for something a little more robust you can read about Basecamp’s Russian Doll Caching to see how they solve these problems.