← Back to Blog

Caching Strategies for Ultra-High Performance in Ruby on Rails, Part 1

When it comes to optimizing web applications, a proper caching strategy is critical because it can significantly reduce load times and improve the overall user experience. This is especially true for Ruby on Rails applications, where performance can often experience bottlenecks due to heavy database queries or complex view rendering.

So, to move Ruby code from low-performance to high-performance, it’s imperative to understand the fundamentals of caching (like fragment and Russian Doll caching) and to maintain and manage a cache for optimal performance. 

In this series, we want to equip developers with the knowledge and tools to enhance the speed and efficiency of their applications. Whether you're dealing with high traffic volumes or need to minimize server load, caching will allow you to maximize performance without compromising scalability or maintainability, transforming your applications' performance under pressure.

Before we begin, we’ll also mention that another way to make sure your Rails apps are ultra-fast is with Scout’s integrated monitoring and logging, which can spot problems before your users do.

What is Caching?

At its core, caching is a technique used to accelerate the delivery of content on the web. When a user requests a webpage, a plethora of operations kick off—from database queries to fetch the latest data to template computation for rendering the view. This process, while comprehensive, can be time-consuming, especially for complex applications. 

Caching mitigates this by storing copies of frequently accessed data in a readily accessible location. So, when the same data is requested again, it can then be served from the cache avoiding the need for anything to be recomputed or fetched from a slower data store. And this can significantly slash response times!

Caching operates at various layers of a web application. On the client side, browsers cache static resources like CSS, JavaScript, and images. Meanwhile, on the side, applications like Ruby on Rails can implement several caching strategies (which we’ll discuss shortly).

So, what are the core benefits of caching?

  • By serving content from the cache, websites can dramatically reduce load times, improving performance and enhancing the user experience.
  • Caching decreases the number of requests the backend must process, reducing server load and saving resources.
  • Efficient caching helps manage increased traffic by minimizing the additional resources required to serve more users, thus offering improved scalability.

But caching isn’t a silver bullet, and it comes with its challenges. For instance, developers must carefully consider what to cache, how long to cache it, and how to invalidate it when the original data changes. Implementing caching strategies requires a thorough understanding of the application's data flow and user behavior patterns to ensure that users always receive the most current content without sacrificing performance.

Let’s review some possible caching strategies available to developers in Ruby on Rails.

Page Caching

Page caching is the simplest and fastest caching strategy in Rails. It generates static HTML files that are served directly by the server without touching the Rails stack. This approach can reduce response times from hundreds of milliseconds to sub-milliseconds.

When implementing page caching, Rails creates a HTML file in the public directory that mirrors your action's URL structure. For example, /products would generate public/products.html. Here's how it works internally:

class ProductsController < ApplicationController
  caches_page :index
  
  def index
    @products = Product.all
    # Rails automatically creates public/products.html after the first request
    # Subsequent requests are intercepted by the web server
  end
  
  # Manually expire the cache when products change
  def update
    @product.update(product_params)
    expire_page action: 'index'
  end
end

Let’s note that page caching was removed from Ruby core in 4.0, so configuration now requires the actionpack-page_caching gem:

# Gemfile
gem 'actionpack-page_caching'

# config/application.rb
config.action_controller.page_cache_directory = Rails.root.join("public/cached_pages")

config/application.rb configures where Rails will store the cached HTML files. By setting it to public/cached_pages, we're telling Rails to create a dedicated directory for cached pages separate from other public assets. This makes it easier to manage and clean up cached content when needed.

Page caching works best for truly static pages or pages that change infrequently, as it bypasses Rails entirely. So, unless you’re building a site that works exactly like that–like marketing pages, documentation, or public blog posts–it isn’t the caching protocol of choice. Take a look at some of its limitations:

  • Page caching can’t be used with per-user content or authentication. When a page requires user-specific content or checks if a user is logged in, page caching won't work because it serves every visitor the same static HTML file.
  • It requires manual cache expiration. Unlike other caching strategies that can automatically invalidate cached content when the underlying data changes, page caching requires developers to explicitly call expire_page whenever content needs updating.
  • This caching needs proper web server configuration for handling both cached and dynamic pages: Your server must be configured to first check for a cached file's existence and serve it directly if found, but also know when to pass requests through to your Rails application. This dual routing setup requires careful configuration to ensure requests are handled correctly.

For dynamic, personalized applications, other caching strategies like action caching or fragment caching are better choices.

Action Caching

Action caching builds upon page caching but allows request filtering through the Rails stack. This makes it suitable for authenticated content while still maintaining high performance. The key difference with action caching is that, before serving the cached content, Rails runs before_action filters.

class DashboardController < ApplicationController
  before_action :authenticate_user!
  caches_action :index, 
                layout: false,
                cache_path: :custom_cache_path,
                expires_in: 1.hour,
                race_condition_ttl: 10

  def index
    @stats = current_user.statistics
  end

  private

  def custom_cache_path
    # Generate unique cache keys per user
    "dashboards/#{current_user.id}-#{current_user.updated_at}"
  end
end

before_action filters are middleware-like hooks that run before controller actions execute. Here, the authenticate_user! filter ensures that only authenticated users can access the dashboard, running this verification check even when serving cached content. This means every request still goes through authentication, but users receive fast cached responses customized to them once authenticated.

Again, like page caching, action caching is no longer part of core, so you need a gem to use it:

gem 'actionpack-action_caching'

A plus (and a minus) of action caching is that it gives developers more adaptability to use sophisticated configurations in their caching setups:

# config/environments/production.rb
config.action_controller.action_cache_store = :mem_cache_store, "cache-1.example.com"

# Advanced configuration with conditions
caches_action :index,
  if: -> { request.format.json? },
  unless: -> { current_user.admin? },
  layout: false,
  race_condition_ttl: 10.seconds

Here, we can set up Memcached as the cache store for production (allowing cached content to be shared across multiple application servers) and add conditional caching rules (in this case, caching only JSON requests from non-admin users).

A few key features of action caching are:

  • Support for customizing cache keys using cache_path. The cache_path option generates unique cache keys for different contexts. In our example, we create user-specific cache keys by combining the user's ID and updated_at timestamp. This makes sure that users see their cached content, which updates when their data changes.
  • Conditional caching with if/unless blocks. Action caching can be selectively applied using conditions. For instance, you might cache responses for regular users but always generate fresh content for administrators or cache only certain response formats.
  • Layout caching control. The layout: false option tells Rails whether to cache just the action's content or include the layout as well. When set to false, Rails will render the layout fresh for each request while serving cached content for the main view, useful when layouts contain dynamic elements like user-specific navigation.
  • Race condition prevention with race_condition_ttl. This feature prevents the thundering herd problem (where many simultaneous requests try to regenerate the same expired cache entry). It adds a small buffer time during which one process regenerates the cache while others continue serving the slightly stale content, preventing database overload.
  • Automatic cache expiration on model updates. Rails automatically invalidates cached content when the associated models change. This happens through the cache key system, which includes model timestamps. When a model is updated, its timestamp changes, generating a new cache key and effectively expiring the old cached content.

These points make action caching ideal for balancing performance with dynamicity for user-specific content that isn’t going to frequently change within a user’s session. But, the extensive configuration to make this work can trip developers up, so use it only when you have well-defined caching needs.

Fragment Caching

Next up, we actually come to a caching mechanism that Rails fully supports: Fragment Caching. Fragment Caching is Rails' most flexible caching mechanism, allowing fine-grained caching of view partials. It's particularly powerful when combined with Rails' cache_digests and Russian Doll caching (see 👇).

Fragment Caching is a great option because…

  • It's granular, and you can cache exactly what you need at whatever level makes sense.
  • It's automatic, and cache invalidation happens automatically when the underlying data changes.
  • It's flexible, meaning you can mix and match different caching strategies within the same view.
  • It's efficient because caching at multiple levels minimizes the work needed when only part of the page changes.

Without further ado, let’s walk through an example of Fragment Caching:

# app/views/products/index.html.erb
<% cache_if user_signed_in?, ["v1", current_user] do %>
  <div class="dashboard">
    <%= render partial: "shared/header" %>
    
    <% cache ["v2", @products.cache_key_with_version] do %>
      <div class="product-grid">
        <%= render partial: "product", collection: @products, cached: true %>
      </div>
    <% end %>
    
    <% cache -> { current_user.preferences_cache_key } do %>
      <%= render "preferences" %>
    <% end %>
  </div>
<% end %>

# app/views/products/_product.html.erb
<% cache_unless admin?, product do %>
  <div class="product" data-product-id="<%= product.id %>">
    <%= render product.variants %>
  </div>
<% end %>

The snippet above shows off multiple features of advanced Fragment Caching. First of all, we see conditional caching based on user authentication:

<% cache_if user_signed_in?, ["v1", current_user] do %>

This introduces conditional caching with cache_if, which only caches content for signed-in users. The cache key includes a version number ("v1") and the current user, ensuring each user gets their own cached version. This is useful for personalized dashboards where content differs between users but might not change frequently for individual users.

Within this block, we then have three distinct caching patterns:

  1. Product grid caching:
<% cache ["v2", @products.cache_key_with_version] do %>
  <div class="product-grid">
    <%= render partial: "product", collection: @products, cached: true %>
  </div>
<% end %>

This is collection caching for handling lists of items. The cache_key_with_version method automatically generates a cache key that includes the timestamp of the most recently updated product, ensuring the cache automatically invalidates when any product changes. The cached: true option tells Rails to cache each individual product partial, improving performance when only a few products in a large collection change.

  1. Dynamic cache keys using lambdas:
<% cache -> { current_user.preferences_cache_key } do %>
  <%= render "preferences" %>
<% end %>

This pattern uses a lambda (the -> syntax) to generate the cache key dynamically. The key advantage here is that the cache key is evaluated at runtime, allowing it to incorporate the latest user preferences. If a user updates their preferences, the lambda will generate a new cache key, automatically invalidating the old cached content.

  1. Role-based conditional caching:
<% cache_unless admin?, product do %>
  <div class="product" data-product-id="<%= product.id %>">
    <%= render product.variants %>
  </div>
<% end %>

This shows role-based cache control using cache_unless. The content is always rendered fresh for admin users, ensuring they see the most up-to-date information. For regular users, the content is cached, improving performance without compromising functionality.

These patterns can be combined to create the specific caching strategies you need to balance performance with content freshness. The nested structure allows different parts of the page to expire independently–for example, a user's preferences might change without requiring the product grid to be re-rendered.

Russian Doll Caching

Russian Doll caching represents Rails' most sophisticated caching pattern, enabling nested cache fragments that automatically handle dependencies. It's particularly effective for complex view hierarchies where different components update at different frequencies. Here’s a good example:

# app/views/categories/show.html.erb
<% cache ["v1", @category] do %>
  <h1><%= @category.name %></h1>
  
  <div class="products">
    <% @category.products.each do |product| %>
      <% cache ["v1", product] do %>
        <div class="product">
          <h2><%= product.name %></h2>
          
          <div class="variants">
            <% product.variants.each do |variant| %>
              <% cache ["v1", variant] do %>
                <%= render "variants/variant", variant: variant %>
              <% end %>
            </div>
          <% end %>
          
          <div class="reviews">
            <% cache ["v1", product.reviews.cache_key_with_version] do %>
              <%= render product.reviews %>
            <% end %>
          </div>
        </div>
      <% end %>
    <% end %>
  </div>
<% end %>

Above, we see a nested caching structure that mirrors the hierarchical nature of our data–categories contain products, which in turn contain variants and reviews. Let's imagine a shopping website where this pattern would be extremely valuable.

At the outermost level, we cache the entire category view using cache ["v1", @category]. Like above, the "v1" version number allows us to invalidate all category caches if we ever need to change how categories are displayed globally. Every time the category itself is updated, this cache will automatically invalidate because Rails uses the category's updated_at timestamp as part of the cache key.

Moving inward, we create another cache fragment with cache ["v1", product] for each product in the category. This creates independent caches for each product, meaning if one product in the category changes (perhaps its price is updated), only that product's cache needs to be regenerated—the rest of the category view remains cached. This selective invalidation is what makes Russian Doll caching so efficient (and gives it its name).

At the deepest levels, we cache variants and reviews independently. Variant caching follows the same pattern as product caching, but notice how reviews use a different approach: cache ["v1", product.reviews.cache_key_with_version]. This is particularly clever because it creates a cache key based on the collection of reviews as a whole, meaning the reviews section will update whenever a review is added, edited, or removed from that product.

This is the ideal caching pattern for sophisticated Rails applications, as it allows you to fine-tune caching at multiple data levels.

Low-Level Caching

Finally, low-level caching provides direct access to Rails' cache store, offering maximum flexibility for caching any data type. This approach is useful for caching expensive computations, API responses, or any arbitrary data.

Let’s play with an example case and some code–magine you're running an e-commerce site where checking the actual inventory status requires communicating with your warehouse system and supplier APIs (operations that might take several seconds and put load on external services):

class Product < ApplicationRecord
  def cached_inventory_status
    Rails.cache.fetch("#{cache_key_with_version}/inventory_status", expires_in: 5.minutes) do
      calculate_inventory_status
    end
  end
  
  private
  
  def calculate_inventory_status
    # Simulate an expensive inventory calculation
    stock_levels = warehouse.check_stock_levels
    supplier_availability = supplier.check_availability
    
    if stock_levels > 10
      "In Stock"
    elsif stock_levels > 0
      "Low Stock"
    elsif supplier_availability
      "Available for Backorder"
    else
      "Out of Stock"
    end
  end
end

The cached_inventory_status method uses Rails' low-level caching interface through Rails.cache.fetch. This method is particularly elegant because it combines reading and writing cache in a single operation. Here's how it works:

  • First, Rails generates a cache key using cache_key_with_version, which includes the product's ID and updated_at timestamp. If any attribute of the product changes, this key will automatically change, invalidating the cache.
  • When cached_inventory_status is called, Rails first uses this key to look for a value in the cache. If it finds one that hasn't expired, it returns that value immediately—there is no need to recalculate anything.
  • If the value is not cached (or has expired), Rails executes the block of code inside the fetch, caches its result, and returns it.

This pattern is perfect for any expensive operation that takes significant time to compute, relies on external services, doesn't need to be perfectly real-time, and can be safely reused.

Coming Soon: Part 2

In part 2, we’ll tackle handling cache stores in Rails. 

In the meantime, keep in mind the key to successful caching isn’t going to be implementing every available strategy–but choosing the right approaches for your needs. To do this, you’ll want to identify your application's performance bottlenecks: Are your database queries slow? Are view renderings taking too long? Is external API communication creating delays? To answer those questions, Scout Monitoring is actually the best place to start your caching journey!

Ready to Optimize Your App?

Join engineering teams who trust Scout Monitoring for hassle-free performance monitoring. With our 3-step setup, powerful tooling, and responsive support, you can quickly identify and fix performance issues before they impact your users.