DEV Community

Cover image for Russian doll caching in Rails
Jeff Kreeftmeijer for AppSignal

Posted on • Edited on • Originally published at blog.appsignal.com

Russian doll caching in Rails

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.

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.

Product variants

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 %>
Enter fullscreen mode Exit fullscreen mode

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>
% end %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 atttibute 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 chanage, so their cached fragments are reused. In the logs, we can see the both the variant and product partials 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)
Enter fullscreen mode Exit fullscreen mode

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

How are you liking our articles about caching in Rails so far? Please don't hesitate to let us know at @AppSignal. Of course, we'd love to know what you'd like us to write about (caching-related or otherwise) too!

Top comments (0)