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.
Rendering a Collection
Let's start with a small controller that loads the last 100 posts for our blog's index page.
1class PostsController < ApplicationController
2 def index
3 @posts = Post.all.order(:created_at => :desc).limit(100)
4 end
5end
To render these posts in the view, we loop over the @posts
instance variable.
1<!-- app/views/posts/index.html.erb -->
2<h1>Posts</h1>
3
4<div class="posts">
5 <% @posts.each do |post| %>
6 <div class="post">
7 <h2><%= post.title %></h2>
8 <small><%= post.author %></small>
9
10 <div class="body">
11 <%= post.body %>
12 </div>
13 </div>
14 <% end %>
15</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.
1Started GET "/posts"
2Processing by PostsController#index as HTML
3 Rendering posts/index.html.erb within layouts/application
4 Post Load (1.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ? [["LIMIT", 100]]
5 ↳ app/views/posts/index.html.erb:4
6 Rendered posts/index.html.erb within layouts/application (19.4ms)
7Completed 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.
1<!-- app/views/posts/index.html.erb -->
2<h1>Posts</h1>
3
4<div class="posts">
5 <% @posts.each do |post| %>
6 <%= render post %>
7 <% end %>
8</div>
9
10<!-- app/views/posts/_post.html.erb -->
11<div class="post">
12 <h2><%= post.title %></h2>
13 <small><%= post.author %></small>
14
15 <div class="body">
16 <%= post.body %>
17 </div>
18</div>
1Started GET "/posts"
2Processing by PostsController#index as HTML
3 Rendering posts/index.html.erb within layouts/application
4 Post Load (1.2ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ? [["LIMIT", 100]]
5 ↳ app/views/posts/index.html.erb:4
6...
7 Rendered posts/_post.html.erb (0.1ms)
8 Rendered posts/_post.html.erb (0.1ms)
9 Rendered posts/index.html.erb within layouts/application (205.4ms)
10Completed 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.
1<!-- app/views/posts/index.html.erb -->
2<h1>Posts</h1>
3
4<div class="posts">
5 <% @posts.each do |post| %>
6 <%= cache post do %>
7 <%= render post %>
8 <% end %>
9 <% end %>
10</div>
1Started GET "/posts"
2Processing by PostsController#index as HTML
3 Rendering posts/index_with_partial_caching.html.erb within layouts/application
4 Post Load (1.4ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ? [["LIMIT", 100]]
5 ↳ app/views/posts/index.html.erb:4
6...
7Read fragment views/posts/index.1ms)
8 Rendered posts/_post.html.erb (0.1ms)
9Write fragment views/posts/index.1ms)
10Read fragment views/posts/index.5ms)
11 Rendered posts/_post.html.erb (0.1ms)
12Write fragment views/posts/index.1ms)
13 Rendered posts/index.html.erb within layouts/application (274.5ms)
14Completed 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.
1Started GET "/posts"
2Processing by PostsController#index as HTML
3 Rendering posts/index.html.erb within layouts/application
4 Post Load (2.2ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ? [["LIMIT", 100]]
5 ↳ app/views/posts/index.html.erb:4
6...
7Read fragment views/posts/index.1ms)
8Read fragment views/posts/index.1ms)
9 Rendered posts/index.html.erb within layouts/application (63.8ms)
10Completed 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:
1# config/environments/development.rb
2config.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.
1<!-- app/views/posts/index.html.erb -->
2<h1>Posts</h1>
3
4<div class="posts">
5 <%= render partial: :post, collection: @posts, cached: true %>
6</div>
Note the render @collection, cached: true
shorthand won't work for this caching speed improvement.
1Started GET "/posts"
2Processing by PostsController#index as HTML
3 Rendering posts/index.html.erb within layouts/application
4 Post Load (1.4ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ? [["LIMIT", 100]]
5 ↳ app/views/posts/index.html.erb:4
6 Rendered collection of posts/_post.html.erb [0 / 100 cache hits] (28.2ms)
7 Rendered posts/index.html.erb within layouts/application (46.6ms)
8Completed 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.
1Started GET "/posts"
2Processing by PostsController#index as HTML
3 Rendering posts/index.html.erb within layouts/application
4 Post Load (1.3ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ? [["LIMIT", 100]]
5 ↳ app/views/posts/index.html.erb:4
6 Rendered collection of posts/_post.html.erb [100 / 100 cache hits] (19.2ms)
7 Rendered posts/index.html.erb within layouts/application (26.5ms)
8Completed 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.
1Rendered 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.