Everything You Ever Wanted To Know About View Caching In Rails

If you've ever built a UI in Rails, you've probably noticed that views tend to get slower over time. That's because adding features to a UI often means adding DB queries to the view. They add up. Fortunately, Rails provides us with an easy-to-apply band-aid in the form of view caching. In this article, Jonathan Miles introduces us to view caching, discusses when it's appropriate to use, and covers common pitfalls to watch out for.

Caching is a general term that means storing the result of some code so that we can quickly retrieve it later. This allows us to, for example, avoid hitting the database over and over to get data that rarely changes. Although the general concept is the same for all types of caching, Rails provides us with different aids depending on what we are trying to cache.

For Rails developers, common forms of caching include memoization, low-level caching (both covered in previous parts of this caching series), and view caching, which we will cover here.

How Ruby on Rails Renders Views

First, let's cover some slightly confusing terminology. What the Rails community calls "views" are the files that live inside your app/views directory. Typically, these are .html.erb files, although there are other options (i.e., plain .html, .js.erb, or files that use other preprocessors, such as slim and haml). In many other web frameworks, these files are called "templates", which I think better describes their use.

When a Rails application receives a GET request, it is routed to a particular controller action, for example, UsersController#index. The action is then responsible for gathering any needed information from the database and passing it on for use in rendering a view/template file. At this point, we are entering the "view layer".

Typically, your view (or template) will be a mixture of hard-coded HTML markup and dynamic Ruby code:

#app/views/users/index.html.erb

<div class='user-list'>
  <% @users.each do |user| %>
    <div class='user-name'><%= user.name %></div>
  <% end %>
</div>

Ruby code in the file needs to be executed to render the view (for erb that's anything in <% %> tags). Refresh the page 100 times, and @users.each... will be executed 100 times. The same is true for any partials included; the processor needs to load the partial html.erb file, execute all the Ruby code inside it, and combine the results into a single HTML file to send back to the requester.

What Causes Slow Views

You've probably noticed that when you view a page during development, Rails prints out a lot of log information, which looks something like the following:

Processing by PagesController#home as HTML
  Rendering layouts/application.html.erb
  Rendering pages/home.html.erb within layouts/application
  Rendered pages/home.html.erb within layouts/application (Duration: 4.0ms | Allocations: 1169)
  Rendered layouts/application.html.erb (Duration: 35.9ms | Allocations: 8587)
Completed 200 OK in 68ms (Views: 40.0ms | ActiveRecord: 15.7ms | Allocations: 14307)

The last line is the most useful to us at this stage. By following the times from left to right, we see that the total time taken by Rails to return a response to the browser was 68ms, of which 40ms was spent rendering erb files and 15.7ms on processing ActiveRecord queries.

Although it is a trivial example, it also shows why we may want to look at caching the view layer. Even if we could magically make ActiveRecord queries happen instantly, we're spending more than twice as long to render the erb.

There are a few reasons our view rendering might be slow; for example, we might be calling expensive DB queries within the views or performing a lot of work within loops. One of the most common situations I've seen is simply rendering a lot of partials, perhaps with multiple levels of nesting.

Imagine an email inbox, where we might have a partial that handles an individual row:

# app/views/emails/_email.html.erb

<li class="email-line">
  <div class="email-sender">
    <%= email.from_address %>
  </div>
  <div class="email-subject">
    <%= email.subject %>
  </div>
</div>

And, in our main inbox page, we render the partial for each email:

# app/views/emails/index.html.erb

...
<% @emails.each do |email| %>
  <%= render email %>
<% end %>

If our inbox has 100 messages, then we are rendering the _email.html.erb partials 100 times. With our trivial example, this is not much of a concern. On my machine, the partial only takes 15ms to render the whole index. Of course, real-world examples would be more complicated and may even include other partials within them; it's not difficult for the render time to increase. Even if it only takes 1-2ms to render our _email partial, it would take 100-200ms to do the whole collection.

Fortunately, Rails has some built-in functionality to help us easily add caching to solve this problem, whether we want to cache just the __email partial, the index page, or both.

What is View Caching

View caching in Ruby on Rails is taking the HTML that a view generates and storing it for later. Although Rails has support for writing these to the filesystem or keeping them in memory, for production use, you'll almost certainly want a standalone caching server, such as Memcached or Redis. Rails' memory_store is useful for development but can't be shared across processes (e.g., multiple servers/dynos or forking servers, such as unicorn). Similarly, the file_store is local to the server. Therefore, it can't be shared across multiple boxes, and it won't delete expired entries automatically, so you'll need to periodically call Rails.cache.clear to prevent your server's disk from getting full.

Enabling a caching store can be done in your environment configuration file (e.g., config/environments/production.rb):

  # memory store is handy for testing
  # during development but not advisable
  # for production
  config.cache_store = :memory_store

In a default installation, your development.rb will already have some configuration done for you to allow easy toggling of caching on your machine. Simply run rails dev:cache to toggle caching on and off.

Caching a view in Rails is deceptively simple, so to illustrate the performance difference, I'll just use sleep(5) to create an artificial delay:

<% cache do %>
  <div>
    <p>Hi <%= @user.name %>
    <% sleep(5) %>
  </div>
<% end %>

Rendering this view the first time takes 5 seconds, as expected. However, loading it up a second time only takes a few milliseconds because everything inside the cache do block is fetched from the cache.

Adding View Caching By Example

Let's take a small example view and walk through our options for caching. We'll assume that this view is actually causing some performance issues:

# app/views/user/show.html.erb
<div>
  Hi <%= @user.name %>!
<div>

<div>
  Here's your list of posts,
  you've written
  <%= @user.posts.count %> so far
  <% @user.posts.each do |post|
    <div><%= post.body %></div>
  <% end %>
</div>

<% sleep(5) #artificial delay %>

This gives us a basic skeleton to work with, along with our artificial 5-second delay. First, we can wrap the whole show.html.erb file in a cache do block, as described earlier. Now, once the cache is warm, we get nice, fast rendering times. It doesn't take long to start seeing issues with this plan, though.

First, what happens if users change their name? We haven't told Rails when to expire our cached page, so the user may never see an updated version. An easy solution is to just pass the @user object to the cache method:

<% cache(@user) do %>
<div>
  Hi <%= @user.name %>!
</div>
...
<% sleep(5) #artificial delay %>
<% end %>

The previous article in this series on low-level caching covered the details of cache keys, so I won't cover it again here. For now, it's enough to know that if we pass a model to cache(), it will use that model's updated_at attribute to generate a key to look up in the cache. In other words, whenever @user is updated, this cached page will expire, and Rails will re-render the HTML.

We've taken care of the case when users change their name, but what about their posts? Changing an existing post or creating a new one won't change the updated_at timestamp of the User, so our cached page won't expire. Additionally, if users change their name, we will re-render all of their posts, even if their posts have not changed. To solve both these problems; we can use "Russian doll caching" (i.e., caches within caches):

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>!
  <div>

  <div>
    Here's your list of posts,
    you've written
    <%= @user.posts.count %> so far<br>
    <% @user.posts.each do |post| %>
      <% cache(post) do %>
        <div><%= post.body %></div>
      <% end %>
    <% end %>
  </div>

  <% sleep(5) #artificial delay %>
<% end %>

We are now caching each individually rendered post (in the real world, this would probably be a partial). Therefore, even if @user is updated, we don't have to re-render the post; we can just use the cached value. We still have one more issue, though. If a post is changed, we still won't render the update because @user.update_at has not changed, so the block inside cache(@user) do will not execute.

To fix this issue, we need to add touch: true to our Post model:

class Post < ApplicationRecord
  belongs_to :user, touch: true
end

By adding touch: true here, we are telling ActiveRecord that every time a post is updated, we want the updated_at timestamp of the user it "belongs to" to also be updated.

I should also add that Rails provides a specific helper for rendering a collection of partials, given how common it is:

  <%= render partial: 'posts/post',
       collection: @posts, cached: true %>

Which is functionally equivalent to the following:

<% @posts.each do |post| %>
  <% cache(post) do %>
    <%= render post %>
  <% end %>
<% end %>

Not only is the render partial: ... cached: true form less verbose, it also gives you some extra efficiency because Rails can issue a multiget to the cache store (i.e., reading many key/value pairs in a single round-trip) rather than hitting your cache store for each item in the collection.

Dynamic Page Content

It's common for some pages to include some amount of 'dynamic' content that changes at a much faster rate than the rest of the page around it. This is particularly true on home pages or dashboards, where you might have activity/news feeds. Including these in our cached page could mean our cache needs to be invalidated frequently, which limits the benefit we get from caching in the first place.

As a simple example, let's add the current day to our view:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>,
    hope you're having a great
    <%= Date.today.strftime("%A") %>!
  <div>

  ...
<% end %>

We could invalidate the cache every day, but that's not very practical for obvious reasons. One option is to use a placeholder value (or even just an empty <span>) and populate it with javascript. This kind of approach is often called "javascript sprinkles" and is an approach largely favored by Basecamp, where a lot of Rails' core code is developed. The result would be something like this:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>,
    hope you're having a great
    <span id='greeting-day-name'>Day</span>!
  <div>

  ...
<% end %>

<script>
 // assuming you're using vanilla JS with turbolinks
 document.addEventListener(
   "turbolinks:load", function() {
   weekdays = new Array('Sunday', 'Monday',
     'Tuesday', 'Wednesday', 'Thursday',
     'Friday', 'Saturday');
     today = weekdays[new Date().getDay()];
   document.getElementById("greeting-day-name").textContent=today;
 });
</script>

Another approach is to cache only some parts of the view. In our example, the greeting is at the top of the page, so it's fairly trivial to only cache what follows:

<div>
  Hi <%= @user.name %>,
  hope you're having a great
  <%= Date.today.strftime("%A") %>!
<div>

<% cache(@user) do %>
  ...
<% end %>

Obviously, this is often not as simple with layouts you find in the real world, so you will have to be considerate about where and how you apply caching.

Words of Warning

It is easy to look at view caching as a quick-and-easy panacea for performance problems. Indeed, Rails makes it incredibly easy to cache views and partials, even when they are deeply nested. In the first article in this series, I laid out the issues that can arise when you add caching into your system, but I think this is particularly true with view-level caching.

The reason is that views, by their very nature, tend to have more interactions with the underlying data of a system. When you apply memoization or low-level caching in Rails, you often don't need to look outside the file you're in to determine when and why the cached value should be refreshed. A view, on the other hand, could have multiple different models being called, and without deliberate planning, it can be difficult to see which models should cause which part of the view to be re-rendered at which time.

As with low-level caching, the best advice is to be strategic about where and when you use it. Use as little caching as you can, in as few places as you can, to achieve an acceptable level of performance.

Rails' Caching by Default

So far in this series on caching we've covered ways of caching things manually but even without any manual configuration, ActiveRecord already does some caching under-the-hood to speed up queries (or skip them entirely). In the next article in this series on caching we'll look at what ActiveRecord is caching for us, and how, with a small amount of work, we can have it keep a "counter cache" so lines like thing.children.size don't have to hit the database at all to get an up to date count.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Jonathan Miles

    Jonathan began his career as a C/C++ developer but has since transitioned to web development with Ruby on Rails. 3D printing is his main hobby but lately all his spare time is taken up with being a first-time dad to a rambunctious toddler.

    More articles by Jonathan Miles
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial