academy

Russian doll caching in Rails

Jeff Kreeftmeijer

Jeff Kreeftmeijer on

Russian doll caching in Rails

Today we will dive into Russian Doll Caching as a tactic to further improve your caching beyond Rails' built-in fragment caching.

Fragment caching

When using Rails’ built-in fragment caching, parts of rendered views are stored as view fragments and reused if they're requested again. These cached fragments are reused until they turn stale, meaning they're outdated because the data they display has changed since creating the fragment. If you want to read more, in this earlier post we explain fragment caching a bit more in depth.

👋 And if you like to read about performance broader than just caching, check out our Ruby performance monitoring checklist.

While this gives a nice speed boost, especially for complex views or views with a lot of rendered partials, we can improve on this some more by doubling down with an approach named Russian doll caching.

When using this caching approach, view fragments are placed inside each other, much like the “Matryoshka” dolls the strategy is named after. By breaking up cached fragments into smaller pieces, the outer cache can be rendered faster when only one of its nested fragments changes.

A Russian doll caching example

As an example, let's use a store that sells products. Each product can have a number of variants, which allow for selling multiple colors of one item, for example. On the index, we'll show each product that's available for sale, as well as all of its variants.

On the product index, we've wrapped each product partial in a cache block. We're using the product object to build the cache key, which is used to invalidate the cached fragment. It consists of the object's id, it's updated_at date, and a digest of the template tree, so it's automatically considered stale if the object changes, or if the template's contents change.

# app/views/products/index.html.erb
<h1>Products</h1>
 
<% @products.each do |product| %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

Tip: We're writing the whole block out for clarity, but you could render each product in a cache block by using <%= render partial: 'products/product', collection: @products, cached: true %> instead.

In the products partial, we render a row for each of the product's variants.

# app/views/products/_product.html.erb
<article>
  <h1><%= product.title %></h1>
 
  <ul>
    <% product.variants.each do |variant| %>
      <%= render variant %>
    <% end %>
  </ul>
</article>

Cache invalidation

Although the cache keys in Rails' fragment caching make cache invalidation easier, you're never fully free of worrying about cache validation (one of the famous two hard things in computer science).

In this example, we cache the products partial, which contains a list of the product's variants. Since the cache key doesn't include any information about the variants, any newly added variants won't show up unless the product itself changes too.

The way to fix this is to make sure the product does change when anything changes in one of its variants. To do that, we'll update the product's updated_at attribute whenever that happens. Since this is so common, there's an argument for belongs_to (and ActiveModel's other relation methods), called :touch, that will automatically update the parent object's updated_at for us.

class Variant < ApplicationRecord
  belongs_to :product, touch: true
end

Nested fragments

Now that we've made sure to update the product fragments when their variants change, it's time to cache the variants too. Like before, we'll add a cache block around each one.

<article>
  <h1><%= product.title %></h1>
 
  <ul>
    <% product.variants.each do |variant| %>
      <% cache(variant) do %>
        <%= render variant %>
      <% end %>
    <% end %>
  </ul>
</article>

Tip: We're writing the whole block out for clarity, but you could render each variant in a cache block by using <%= render partial: 'variants/variant', collection: product.variants, cached: true %> instead.

On a cold cache (you can clear the cache by running rake tmp:cache:clear), the first request will render each product partial.

When requesting the page now (don't forget to turn on caching in development by running rails dev:cache), each product partial will be cached as a partial, and the second request will return the cached fragments.

Started GET "/products" for 127.0.0.1 at 2018-03-30 14:51:38 +0200 Processing by ProductsController#index as HTML Rendering products/index.html.erb within layouts/application Product Load (0.2ms) SELECT "products".* FROM "products" Variant Load (0.9ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51) Rendered variants/_variant.html.erb (0.5ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.0ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered products/_product.html.erb (44.8ms) [cache miss] ... Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered variants/_variant.html.erb (0.1ms) Rendered products/_product.html.erb (46.2ms) [cache miss] Rendered products/index.html.erb within layouts/application (1378.6ms) Completed 200 OK in 1414ms (Views: 1410.5ms | ActiveRecord: 1.1ms)

Started GET "/products" for 127.0.0.1 at 2018-03-30 14:51:41 +0200 Processing by ProductsController#index as HTML Rendering products/index.html.erb within layouts/application Product Load (0.3ms) SELECT "products".* FROM "products" Variant Load (12.7ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51) Rendered products/index.html.erb within layouts/application (48.1ms) Completed 200 OK in 76ms (Views: 59.0ms | ActiveRecord: 13.0ms)

Voila: Russian doll magic

The magic of Russian doll caching can be seen when changing one of the variants. When requesting the index again after one of the variants change, the cached product fragment is rerendered because its updated_at attribute changed.

The product partial includes each of the product's variants. The cached fragment for the variant we just changed is stale, so it needs to be regenereated, but the other variants didn't change, so their cached fragments are reused. In the logs, we can see that both the variant and product partials are being rendered once.

Started GET "/products" for 127.0.0.1 at 2018-03-30 14:52:04 +0200 Processing by ProductsController#index as HTML Rendering products/index.html.erb within layouts/application Product Load (0.3ms) SELECT "products".* FROM "products" Variant Load (1.2ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51) Rendered variants/_variant.html.erb (0.5ms) Rendered products/_product.html.erb (13.3ms) [cache miss] Rendered products/index.html.erb within layouts/application (45.9ms) Completed 200 OK in 78ms (Views: 73.5ms | ActiveRecord: 1.5ms)

The endresult

By nesting cache fragments like this, the view is almost never rendered in its entirety, unless the cache is completely empty. Even when the data changes, most of the rendered pages are served straight from the cache.

We hope this helped you get new insights in performance of your apps. That's why we are here. If you liked this article on caching, and you are hungry for more, some extra snacks are our posts about ActiveRecord's Counter Cache, the one about Cache Stores in Rails, the post about collection caching and the one about fragment caching in Rails we mentioned earlier in the post.

Share this article

RSS

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps