# Rails Collection Caching

Tom de Bruijn on

We've previously looked at fragment caching in Rails on AppSignal Academy. This greatly improves the performance of views by caching smaller pieces of them. When caching partials, we have the added benefit of being able to reuse them elsewhere in our views at little cost.

This works well for small collections, but problems quickly arise on larger collections. In this article, we'll take a look at how Rails collection caching works and how we can use it to speed up the rendering of a large collection.

👋 And if you like this article, there is a lot more we wrote about Ruby (on Rails) performance, check out our Ruby performance monitoring checklist.

## Rendering a Collection

Let's start with a small controller that loads the last 100 posts for our blog's index page.

class PostsController < ApplicationController
def index
@posts = Post.all.order(:created_at => :desc).limit(100)
end
end

To render these posts in the view, we loop over the @posts instance variable.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>

<div class="posts">
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<small><%= post.author %></small>

<div class="body">
<%= post.body %>
</div>
</div>
<% end %>
</div>

Upon requesting this page, we see the posts being fetched from the database and the view being rendered. With only 32 milliseconds spent in the view layer, this page is pretty fast.

Started GET "/posts"
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (1.5ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
↳ app/views/posts/index.html.erb:4
Rendered posts/index.html.erb within layouts/application (19.4ms)
Completed 200 OK in 37ms (Views: 32.4ms | ActiveRecord: 2.7ms)


## Rendering a Collection with Partials

Next, we want to use the post element in another view, so we move the post HTML to a partial.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>

<div class="posts">
<% @posts.each do |post| %>
<%= render post %>
<% end %>
</div>

<!-- app/views/posts/_post.html.erb -->
<div class="post">
<h2><%= post.title %></h2>
<small><%= post.author %></small>

<div class="body">
<%= post.body %>
</div>
</div>
Started GET "/posts"
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (1.2ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
↳ app/views/posts/index.html.erb:4
...
Rendered posts/_post.html.erb (0.1ms)
Rendered posts/_post.html.erb (0.1ms)
Rendered posts/index.html.erb within layouts/application (205.4ms)
Completed 200 OK in 217ms (Views: 213.8ms | ActiveRecord: 1.7ms)


With 213 milliseconds spent on the view layer, you can see that the render time has increased substantially. This is because a new file (the partial) needs to be loaded, compiled and rendered for every post. Let's briefly look at how we can improve the render time with fragment caching.

## Fragment Caching

As described in the fragment caching article, we'll use the cache helper in the view around the render call. In this way, we'll cache the rendering of the partial for every post.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>

<div class="posts">
<% @posts.each do |post| %>
<%= cache post do %>
<%= render post %>
<% end %>
<% end %>
</div>
Started GET "/posts"
Processing by PostsController#index as HTML
Rendering posts/index_with_partial_caching.html.erb within layouts/application
Post Load (1.4ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
↳ app/views/posts/index.html.erb:4
...
Rendered posts/_post.html.erb (0.1ms)
Write fragment views/posts/index.1ms)
Rendered posts/_post.html.erb (0.1ms)
Write fragment views/posts/index.1ms)
Rendered posts/index.html.erb within layouts/application (274.5ms)
Completed 200 OK in 286ms (Views: 281.4ms | ActiveRecord: 2.4ms)


The first request won't be that much faster, because it still needs to render every partial the first time around and store it in the cache store.

Started GET "/posts"
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (2.2ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
↳ app/views/posts/index.html.erb:4
...
Rendered posts/index.html.erb within layouts/application (63.8ms)
Completed 200 OK in 78ms (Views: 75.5ms | ActiveRecord: 2.2ms)


In subsequent requests, we see that the time spent in the view is considerably lower - from 286 milliseconds down to 78 milliseconds. Yet, it's still a lot slower than what we got with our original code - it's almost twice as slow.

Note: If you're not seeing the "Read/Write fragment" lines in your logs, be sure to enable fragment cache logging in your development environment, which is set to false by default on Rails 5.1 and above:

# config/environments/development.rb
config.action_controller.enable_fragment_cache_logging = true


## Collection Caching

In Rails 5, a lot of work was done to make collection caching faster. To leverage these improvements, we'll need to change our view code. Instead of calling the cache helper ourselves, we can ask Rails to render an entire collection and cache it at the same time.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>

<div class="posts">
<%= render partial: :post, collection: @posts, cached: true %>
</div>

Note the render @collection, cached: true shorthand won't work for this caching speed improvement.

Started GET "/posts"
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (1.4ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
↳ app/views/posts/index.html.erb:4
Rendered collection of posts/_post.html.erb [0 / 100 cache hits] (28.2ms)
Rendered posts/index.html.erb within layouts/application (46.6ms)
Completed 200 OK in 64ms (Views: 59.9ms | ActiveRecord: 2.0ms)


On the first request, we can already see a large improvement in time spent on the view layer. This is because Rails now prepares in advance, the partial being used for the entire collection, rather than for each post separately.

Started GET "/posts"
Processing by PostsController#index as HTML
Rendering posts/index.html.erb within layouts/application
Post Load (1.3ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
↳ app/views/posts/index.html.erb:4
Rendered collection of posts/_post.html.erb [100 / 100 cache hits] (19.2ms)
Rendered posts/index.html.erb within layouts/application (26.5ms)
Completed 200 OK in 37ms (Views: 35.7ms | ActiveRecord: 1.3ms)


In subsequent requests, we see even more improvement - from 64 milliseconds down to about 35 milliseconds. A big speed improvement for the entire collection is made here by Rails optimization for collections. Instead of checking the availability of a cache for every partial, Rails checks all cache keys of the collection at the same time, saving time querying the cache store.

An added benefit of this caching helper is the summarized logging of the collection. In the first request, none of the cache keys were found [0 / 100 cache hits], but in the second request, they were all found [100 / 100 cache hits].

After updating some of the objects in the database, we can even see how many keys were stale.

Rendered collection of posts/_post.html.erb [88 / 100 cache hits] (13.4ms)


There's much speed improvement to gain with this optimized collection rendering and caching. An even bigger difference will be made when rendering larger collections. Unless you need customized views for your collection, this optimized strategy is the way to go for your Rails apps. At AppSignal, we managed to significantly speed up one of our admin views that was rendering thousands of records, in this way.

Have any questions about caching collections in Rails? Please don’t hesitate to let us know at @AppSignal! If you have any comments regarding the article or if you have any topics that you'd like us to cover, then please get in touch with us.