Ruby
Article

Speed Things up by Learning about Caching in Rails

By Ilya Bodrov-Krukowski

As developers, we hear the word “cache” quite often. Actually, it means “to hide” in French (“cache-cache” is a hide-and-seek game). Caching basically means that we store some data so that subsequent requests are being fulfilled faster, without the need to produce that data once again.

Caching can really provide great benefits by speeding up your web application and improving its user experience. Today we are going to discuss various caching techniques available in Rails: page, action, fragment, model and HTTP caching. You are then free to choose one of them or even use multiple techniques in conjunction.

The source code can be found on GitHub.

Turning Caching On

For this demo I’ll be using Rails 5 beta 3, but most of the information is applicable to Rails 4, as well. Go ahead and create a new app:

$ rails new BraveCacher -T

As you probably know, caching is disabled by default in the development environment, so you will need to turn it on. In Rails 5, however, this is done differently. Just run the following command:

$ rake dev:cache

What this command basically does is creates an empty caching-dev.txt file inside the tmp directory. Inside the development.rb there is now the following code:

config/environments/development.rb

if Rails.root.join('tmp/caching-dev.txt').exist?
  config.action_controller.perform_caching = true
  config.action_mailer.perform_caching = false
  config.cache_store = :memory_store
  config.public_file_server.headers = {
    'Cache-Control' => 'public, max-age=172800'
  }
else
  config.action_controller.perform_caching = false
  config.action_mailer.perform_caching = false
  config.cache_store = :null_store
end

This file signals whether to enable or disable caching – you don’t need to tweak the config.action_controller.perform_caching setting anymore. Other differences between Rails 4 and 5 can be found in one of my previous articles.

Please note that if you are using Rails 5 beta, there is a bug when booting server using rails s. To put it simply, the caching-dev.txt file is being removed every time, so for the time being use the following command instead:

$ bin\rails server -C

This bug is already fixed in the master branch.

Okay, now the caching is enabled and we can proceed to the next section. Let’s discuss page caching first.

Page Caching

As someone once said, thank God if you are able to use page caching because it adds a huge boost to your app’s performance. The idea behind this technique is simple: the whole HTML page is saved to a file inside the public directory. On subsequent requests, this file is being sent directly to the user without the need to render the view and layout again.

Unfortunately, such a powerful solution has very limited usage. If you have many pages that look different for different users, page caching is not the best option. Also, it cannot be employed in scenarios where your website may be accessed by authorized users only. However, it shines for semi-static pages.

Starting from Rails 4, page caching has been extracted to a separate gem, so add it now:

Gemfile

[...]
gem 'actionpack-page_caching'
[...]

Run

$ bundle install

Now, tweak your configuration file:

config/application.rb

[...]
config.action_controller.page_cache_directory = "#{Rails.root.to_s}/public/deploy"
[...]

This setting is needed to specify where to store your cached pages.

Let’s introduce a very simple controller and a view to test our new caching technique.

pages_controller.rb

class PagesController < ApplicationController
  def index
  end
end

config/routes.rb

[...]
root 'pages#index'
[...]

views/pages/index.html.erb

<h1>Welcome to my Cached Site!</h1>

Page caching is enabled per-action by using caches_page method. Let’s cache our main page:

pages_controller.rb

class PagesController < ApplicationController
  caches_page :index
end

Boot the server and navigate to the root path. You should see the following output in the console:

Write page f:/rails/my/sitepoint/sitepoint-source/BraveCacher/public/deploy/index.html (1.0ms)

Inside the public/deploy directory, there will be a file called index.html that contains all the markup for the page. On subsequent requests, it will be sent without the need to go through the ActionPack.

Of course, you will also need to employ cache expiration logic. To expire your cache, use the expire_page method in your controller:

expire_page action: 'index'

For example, it your index page lists an array of products, you could add expire_page into the create method. Alternatively, you may use a sweeper as described here.

Action Caching

Action caching works pretty much like page caching, however instead of immediately sending the page stored inside the public directory, it hits Rails stack. By doing this, it runs before actions that can, for example, handle authentication logic.

Action caching was also extracted to a separate gem as well, so add it:

Gemfile

[...]
gem 'actionpack-action_caching'
[...]

Run

$ bundle install

Let’s add a new restricted page with very naive authorization logic. We will use Action Caching to cache the action by calling the caches_action method:

class PagesController < ApplicationController
  before_action :authenticate!, only: [:restricted]

  caches_page :index
  caches_action :restricted

  def index
  end

  def restricted
  end

  private

  def authenticate!
    params[:admin] == 'true'
  end
end

views/pages/restricted.html.erb

<h1>Restricted Page</h1>

config/routes.rb

get '/restricted', to: 'pages#restricted'

Under the hood, this technique uses fragment caching, that’s why you will see the following output when accessing your restricted page:

Write fragment views/localhost:3000/restricted

Expiration is done similarly to page caching – just issue the expire_action method and pass controller and action options to it. caches_action also accepts a variety of options:

  • if or unless to instruct whether to cache the action
  • expires_in – time to expire cache automatically
  • cache_path – path to store the cache. Useful for actions with multiple possible routes that should be cached differently
  • layout – if set to false, will only cache the action’s content
  • format – useful when your action responds with different formats

Fragment Caching

Fragment caching, as the name implies, caches only the part of your page. This functionality exists in Rails’ core, so you don’t have to add it manually.

Let’s introduce a new model called Product, a corresponding controller, view, and route:

$ rails g model Product title:string
$ rake db:migrate

products_controller.rb

class ProductsController < ApplicationController
  def index
    @products = Product.all
  end
end

config/routes.rb

resources :products, only: [:index]

views/products/index.html.erb

<h1>Products</h1>

<% @products.each do |product| %>
  <%= product.title %>
<% end %>

Populate the products table with sample data:

db/seeds.rb

20.times {|i| Product.create!({title: "Product #{i + 1}"})}

And run

$ rake db:seed

Now, suppose we want to cache each product listed on the page. This is done using the cache method that accepts cache storage’s name. It can be a string or an object:

views/products/index.html.erb

<% @products.each do |product| %>
  <% cache product do %>
    <%= product.title %>
  <% end %>
<% end %>

Or

views/products/index.html.erb

<% @products.each do |product| %>
  <% cache "product-#{product.id}" do %>
    <%= product.title %>
  <% end %>
<% end %>

If you pass an object to the cache method, it will take its id automatically, append a timestamp and generate a proper cache key (which is an MD5 hash). The cache will automatically expire if the product was updated.

In the console you should see an output similar to this one:

Write fragment views/products/12-20160413131556164995/0b057ac0a9b2a20d07f312c2f31bde45

This code can be simplified using the render method with the cached option:

views/products/index.html.erb

<%= render @products, cached: true %>

You can go further and apply so-called Russian doll caching:

views/products/index.html.erb

<% cache "products" do %>
  <%= render @products, cached: true %>
<% end %>

There are also cache_if and cache_unless methods that are pretty self-explainatory.

If you wish to expire your cache manually, use the expire_fragment method and pass the cache key to it:

@product = Product.find(params[:id])
# do something...
expire_fragment(@product)

Model Caching

Model caching (aka low level caching) is often used to cache a particular query, however, this solution can be employed to store any data. This functionality is also a part of Rails’ core.

Suppose we want to cache a list of our products fetched inside the index action. Introduce a new class method for the Product:

models/product.rb

[...]
class << self
  def all_cached
    Rails.cache.fetch("products") { Product.all }
  end
end
[...]

The fetch method can both read and write Rails’ cache (the first argument is the storage’s name). If the requested storage is empty, it will be populated with the content specified inside the block. If it contains something, the result will simply be returned. There are also write and read methods available.

In this example, we create a new storage called “products” that is going to contain a list of our products. Now use this new method inside the controller’s action:

products_controller.rb

[...]
def index
  @products = Product.all_cached
end
[...]

Of course, you’ll need to implement cache expiration logic. For simplicity, let’s do it inside the after_commit callback:

models/product.rb

[...]
after_commit :flush_cache
[...]
private

def flush_cache
  Rails.cache.delete('products')
end
[...]

After any product is updated, we invalidate the “products” cache.

Model caching is pretty simple to implement and can significantly speed up your complex queries.

HTTP Caching

The last type of caching we are going to discuss today is HTTP caching that relies on HTTP_IF_NONE_MATCH and HTTP_IF_MODIFIED_SINCE headers. Basically, these headers are being sent by the client to check when the page’s content was last modified and whether its unique id has changed. This unique id is called an ETag and is generated by the server.

The client receives an ETag and sends it inside the HTTP_IF_NONE_MATCH header on subsequent requests. If the ETag sent by the client does not match the one generated on the server, it means the page has been modified and needs to be downloaded again. Otherwise, a 304 (“not modified”) status code is returned and a browser uses a cached copy of the page. To learn more, I highly recommend reading this article by Google that provides very simple explanations.

There are two methods that can be used to implement HTTP caching: stale? and fresh_when. Suppose we want to cache a show page:

products_controller.rb

[...]
def show
  @product = Product.find(params[:id])
end
[...]

views/products/show.html.erb

<h1>Product <%= @product.title %></h1>

config/routes.rb

[...]
resources :products, only: [:index, :show]
[...]

Use the stale? method and set the :last_modified and :etag options:

products_controller.rb

[...]
def show
  @product = Product.find(params[:id])
  if stale?(last_modified: @product.updated_at.utc, etag: @product.cache_key)
    respond_to do |format|
      format.html
    end
  end
end
[...]

The idea is simple – if the product was recently updated, the cache will be invalidated and the client will have to download the page once again. Otherwise, Rails will send a 304 status code. cache_key is a special method that generates a proper unique id for a record.

You may further simplify the code

products_controller.rb

[...]
def show
  @product = Product.find(params[:id])
  if stale?(@product)
    respond_to do |format|
      format.html
    end
  end
end
[...]

stale? will fetch the product’s timestamp and cache key automatically. fresh_when is a simpler method that can be used if you don’t want to utilize respond_to:

products_controller.rb

[...]
def show
  @product = Product.find(params[:id])
  fresh_when @product
end
[...]

It also accepts :last_modified and :etag options, just like stale?.

HTTP caching may be hard to implement, especially for complex pages, but having it in place can really boost web site’s performance.

Conclusion

In this article we’ve discussed various caching techniques that you can employ in your Rails apps. Remember, however, that preliminary optimization is evil, so you should assess which caching solution suits you before implementing it.

Which caching techniques do you use in your apps? Share your experience in the comments!

  • Evgeniy

    Do you know, is it possible to implement Rails caching via Redis storage engine?

    • Svashtar

      It is. Google a bit about it, lots of articles arround

  • Влад

    Great article. But can you describe more particularly the benefits of Russian Doll Caching? I understood main concepts (about reusable low-level fragments and chain expiration), but where is advantages? I found something like this: “Every nested cache of the parent can be reused, which provides a significant performance increase”. Okay, but where is performance increase?

    • Ilya Bodrov

      Quote from the guides: “The advantage of Russian doll caching is that if a single product is updated, all the other inner fragments can be reused when regenerating the outer fragment”. So basically, if one of the records is updated, we expire it, but other records are left intact – we don’t have to regenerate cache for them.

  • http://janis-vitols.com Jānis

    Thanks for such great cheat-sheet post which can be reused any time when some caching will be needed :)

    I have used all of them, but only in case of tutorials, courses or book examples. In real project thought I have only used model caching – caching complex queries and things like that :) Btw caching is tricky when need to debug some problems related to it – remember spending few hours by investigating problem :)

    • Ilya Bodrov

      Glad it helped!

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in Ruby, once a week, for free.