Take Control of Your HTTP Caching in Rails

Damian Galarza

The Rails fresh_when method is a powerful tool for conditionally caching resources via HTTP. However there are some pitfalls. For one, fresh_when only supports the default render flow in a controller; if a client’s cache is not fresh, it will just render the related view. We cannot utilize things like render json:.

Fortunately, Rails provides us with more tools to work with HTTP conditional caching. Some of the basics behind HTTP conditional caching are assumed in this post. If you haven’t already, or you just need a refresher take a look at Introduction to Conditional HTTP Caching with Rails.

Let’s work with a simple Rails blog application which renders posts as JSON.

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    render json: @post
  end
end

# config/routes
Rails.application.routes.draw do
  resources :posts, only: [:show]
end

What if we wanted to allow the client to conditionally cache this content? Rails provides us with the stale? method which takes a resource and checks to see if the ETag provided by the client via the If-None-Matches header matches the one which represents the resource’s current state. The stale? method can also work with the If-Modified-Since header. It will check if the resource’s updated_at has been modified since the timestamp provided by If-Modified-Since. To utilize this, we just need to wrap our render call with a call to stale?.

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])

    if stale?(@post)
      render json: @post
    end
  end
end

If we make a request to the individual post we’ll get something similar to the following response:

curl -i http://localhost:3000/posts/1
HTTP/1.1 200 OK
Etag: "f049a9825a0239db3d01ead65a5ea522"
Last-Modified: Tue, 02 Dec 2014 18:31:42 GMT
Content-Type: application/json; charset=utf-8
Cache-Control: max-age=0, private, must-revalidate
Server: WEBrick/1.3.1 (Ruby/2.1.5/2014-11-13)
Date: Tue, 02 Dec 2014 18:31:48 GMT
Content-Length: 93
Connection: Keep-Alive

{"post":{"title":"Blog post 0","body":"lorem ipsum","created_at":"2014-11-12T15:30:34.025Z"}}%

If we take a look at our Rails server log, we’ll see something similar to the following:

Started GET "/posts/1" for 127.0.0.1 at 2014-12-02 13:38:18 -0500
Processing by PostsController#show as */*
  Parameters: {"id"=>"1"}
  Post Load (0.2ms)  SELECT  "posts".* FROM "posts"  WHERE "posts"."id" = $1
LIMIT 1  [["id", 1]]
Completed 200 OK in 1ms (Views: 0.3ms | ActiveRecord: 0.2ms)

Now if we make a request with the If-None-Match header set to the ETag that the server provided us with earlier:

curl -i -H 'If-None-Match: "f049a9825a0239db3d01ead65a5ea522"' http://localhost:3000/posts/1

We’ll now receive a 304 Not Modified status for our request.

HTTP/1.1 304 Not Modified
Etag: "f049a9825a0239db3d01ead65a5ea522"
Last-Modified: Tue, 02 Dec 2014 18:31:42 GMT
Cache-Control: max-age=0, private, must-revalidate
Server: WEBrick/1.3.1 (Ruby/2.1.5/2014-11-13)
Date: Tue, 02 Dec 2014 18:32:53 GMT
Connection: Keep-Alive

If we take a look at our Rails log we’ll notice that the server skipped over the process of rendering the JSON all together:

Started GET "/posts/1" for 127.0.0.1 at 2014-12-02 13:38:55 -0500
Processing by PostsController#show as */*
  Parameters: {"id"=>"1"}
  Post Load (0.2ms)  SELECT  "posts".* FROM "posts"  WHERE "posts"."id" = $1
LIMIT 1  [["id", 1]]
Completed 304 Not Modified in 1ms (ActiveRecord: 0.2ms)

In this trivial example our gain is not huge. However with more complicated JSON objects this can be a very useful. A common example is a larger JSON response built via ActiveModel::Serializers. Combined with key-based caching in ActiveModel::Serializers this can be a powerful tool. We can cache individual items with russian doll caching, and then cache the entire response via HTTP caching.

Further Reading