7 Rails Caching Patterns That Slashed My Page Load Times From 3s to 200ms

Learn 7 Rails caching patterns—fragment, HTTP, Russian doll, and more—with real code examples to cut load times and reduce database strain fast.

7 Rails Caching Patterns That Slashed My Page Load Times From 3s to 200ms

I remember the first time I pushed a Rails app to production and watched it crumble under the weight of a few hundred users. The database queries were screaming, the pages took forever to load, and I sat there staring at the logs like a deer in headlights. That night I learned about caching. Not as a theory, but as a survival tool. Over the years I have used seven patterns that saved me again and again. I will walk you through each one with the simplest explanation I can give, because I want you to avoid that same panic.

Caching is just keeping a copy of something expensive so you do not have to rebuild it every time. Think of it like baking a cake for a party. If you know guests will keep asking for slices, you do not bake a new cake each time. You cut from the one you already made. In Rails, the expensive thing is often a database query, a rendered view, or an API call. We keep the result somewhere fast, like memory or a key-value store, and serve it until it goes stale.

Here is the foundation: Rails uses a cache store. You set it in config/environments/production.rb. For most apps, I use Memcached or Redis. Here is how I set up Redis:

# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
  expires_in: 1.hour
}

That line tells Rails to store cached data in Redis and expire anything older than one hour. Simple. Now let me show you the first pattern.

Pattern 1: Page Caching – The Old Guard

Page caching is the fastest form of caching. It saves the entire HTML response as a static file. Next time someone requests that URL, the web server serves the file directly, without even touching Rails. I have used this for public pages that rarely change, like a site’s homepage or a blog post.

To use it, you add caches_page in your controller:

class HomeController < ApplicationController
  caches_page :index

  def index
    @articles = Article.published
  end
end

But there is a catch. Page caching skips all filters, including authentication. So if your page shows a logged-in user’s name, page caching will serve the same name to everyone. That is why I only use it for truly public content. Also, Rails 4 removed built-in page caching. You need the actionpack-page_caching gem. I still use it for a few static landing pages because it is blazing fast.

# Gemfile
gem 'actionpack-page_caching'

Then in your controller, you can expire the cache when the page changes:

class ArticlesController < ApplicationController
  caches_page :show

  def show
    @article = Article.find(params[:id])
  end

  # When an article is updated, remove the cached page
  def update
    @article = Article.find(params[:id])
    if @article.update(article_params)
      expire_page action: :show, id: @article.id
      redirect_to @article
    end
  end
end

The expire_page call deletes the static HTML file. Next visitor gets a fresh one. This pattern works best when you have a moderate number of pages that change rarely. For a blog with 1000 posts, it is fine. For a forum with millions, it becomes a headache because you have to manage expiration manually.

Pattern 2: Action Caching – A Bit More Flexible

Action caching is like page caching, but it still runs the before filters. That means you can use it for pages that require authentication but not much else. The response is cached as a whole, but filters like authenticate_user! still run.

I used this for a dashboard that showed different data per user role, but the same data for all admins. The cache key included the role, so admins saw a cached version while regular users got their own.

class DashboardController < ApplicationController
  caches_action :index, expires_in: 10.minutes

  def index
    @stats = DashboardStats.compute
  end
end

But I stopped using action caching because it caches the entire response including the layout. If you change the layout, you have to sweep all caches. Also, it does not work well with flash messages or dynamic content. Most modern Rails apps use fragment caching instead.

Pattern 3: Fragment Caching – The Workhorse

Fragment caching is the pattern I use every day. Instead of caching a whole page, you cache a piece of it. For example, a list of recent comments or a sidebar. This way you can update parts independently.

In your view, wrap the expensive part with a cache block:

<% @articles.each do |article| %>
  <% cache article do %>
    <div class="article">
      <h2><%= article.title %></h2>
      <p><%= truncate(article.body, length: 200) %></p>
      <%= link_to 'Read more', article %>
    </div>
  <% end %>
<% end %>

The cache helper uses the record’s cache_key which includes its id and updated_at timestamp. When the article changes, the cache is invalidated automatically. This is the easiest way to get started. I do this for all my index views.

But what about loops where you have a lot of records? If you cache each article in a loop, you still render the outer loop. That is fine for 50 items. For 500, you want to cache the whole list too. You can nest fragment caches:

<% cache 'articles-list' do %>
  <% @articles.each do |article| %>
    <% cache article do %>
      ...
    <% end %>
  <% end %>
<% end %>

Now the entire list is cached until any article changes. But when one article updates, the outer cache expires because the inner caches change? No. That is the problem. The outer cache key does not know about inner ones. You need a different approach.

Pattern 4: Russian Doll Caching – Nested Heaven

Russian doll caching solves the nesting problem by using a cache key that includes the collection’s last update time. Here is how it works.

In your controller, add a cache key for the collection:

@articles = Article.published
@cache_key = "articles/#{@articles.maximum(:updated_at).to_i}"

Then in your view:

<% cache @cache_key do %>
  <% @articles.each do |article| %>
    <% cache [article, 'show'] do %>
      <div class="article">
        <h2><%= article.title %></h2>
        <p><%= truncate(article.body, length: 200) %></p>
        <%= link_to 'Read more', article %>
      </div>
    <% end %>
  <% end %>
<% end %>

When any article updates, the outer cache key changes because maximum(:updated_at) changes. The inner caches are still valid for unchanged articles. So Rails renders the outer cache fresh but reuses the inner fragments that did not change. This saves a ton of time.

Rails 5 introduced a helper cache_collections that does this automatically. But I still like to be explicit. I have used this pattern on a site with 10,000 products and it cut page load times from 3 seconds to 200 milliseconds.

One trick I use is to include the locale in the cache key for multi-lingual sites:

<% cache [I18n.locale, 'articles-list', @articles.maximum(:updated_at)] do %>

Pattern 5: Low-Level Caching – When You Need to Cache a Value

Sometimes you do not need to cache a whole view fragment. You just need to cache a computed value, like the result of a heavy query or an API call. That is low-level caching.

Rails gives you Rails.cache.fetch. It works like this:

def expensive_stat
  Rails.cache.fetch('site_stats', expires_in: 1.hour) do
    # Expensive calculation
    {
      total_users: User.count,
      total_orders: Order.completed.count,
      revenue: Order.completed.sum(:total)
    }
  end
end

You call expensive_stat and the first time it computes and stores in Redis. Every subsequent call within an hour gets the cached value. I use this for dashboard widgets and sidebars.

You can also pass dynamic keys:

def user_activity(user)
  Rails.cache.fetch("user_activity/#{user.id}", expires_in: 5.minutes) do
    user.recent_activity
  end
end

But be careful with keys that change frequently. If every user has a unique key, you might fill up your cache store. Set a reasonable expiration or use a small TTL.

I once built a leaderboard that recomputed scores every minute. I cached the SQL result with a 60-second expiry. The load on the database dropped by 95%.

You can also cache entire query results using Relation caching:

# In model
def self.featured_products
  Rails.cache.fetch('featured_products', expires_in: 10.minutes) do
    where(featured: true).order(:name).to_a
  end
end

to_a is important because it forces the query to execute now and returns an array, which can be serialized. The cached array behaves like any collection in your view.

Pattern 6: HTTP Caching – Tell the Browser to Hold On

HTTP caching works on the request level. You send headers that tell the browser or a proxy (like Cloudflare) to keep a copy of the response. The browser will not even ask your server for the page until the cache expires or the content changes.

Rails provides fresh_when and stale? helpers. I use fresh_when on show pages.

class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
    fresh_when @article
  end
end

fresh_when checks the Last-Modified timestamp and the ETag (a hash of the record). If the request’s If-Modified-Since or If-None-Match headers match, Rails returns a 304 Not Modified with an empty body. The browser uses its cached version. This saves bandwidth and server time.

You can customize the ETag to include other dependencies:

def show
  @article = Article.find(params[:id])
  @comments = @article.comments.includes(:user)
  fresh_when @article, etag: @comments
end

Now if a comment is added, the ETag changes and the browser fetches a fresh copy.

For index pages, I use a combination:

def index
  @articles = Article.published
  fresh_when last_modified: @articles.maximum(:updated_at), etag: @articles
end

But be careful with fresh_when on authenticated pages. If a logged-in user sees different content, do not use HTTP caching globally. Instead, make the ETag include user-specific data, but that defeats the purpose. I use HTTP caching only for public pages.

Pattern 7: SQL Query Caching – The Freebie

Rails automatically caches queries within a single request. This is not something you configure. It just works. When you call User.find(1) twice in the same request, the second call returns the same object without hitting the database again.

But this only works within one request. If you want to cache results across requests, use low-level caching. That said, SQL query caching is a pattern you should be aware of because you can accidentally break it.

For example, if you use reload:

user = User.find(1)
user.reload  # Forces a new query, bypasses cache

Or if you use raw SQL:

User.find_by_sql("SELECT * FROM users WHERE id = 1")  # Not cached

Moral of the story: stay in ActiveRecord as much as possible. Let Rails handle the query caching.

Now, let me talk about some practical tips I have learned the hard way.

First, expiration is everything. It does not matter how fast your cache is if you serve stale data. I set short expirations on data that changes often and longer ones on static data. For a product listing, I use 10 minutes. For a blog post, I use 1 day.

Second, use cache keys with version numbers. When you change the template, you want old caches to die immediately. I add a version to the cache key:

<% cache ['v2', article] do %>

When I change the view, I bump the version. That invalidates all caches under that key. You can also use a timestamp, but I find version numbers easier.

Third, monitor your cache hit ratio. If you use Redis, run redis-cli info stats and look at keyspace_hits and keyspace_misses. A good hit ratio is above 90%. If it is low, your expiration times are too short or your keys are wrong.

I once had a cache key that included Time.now by accident. Every request had a unique key, so it never hit. My cache store filled up with junk. I fixed it by removing the time component.

Fourth, avoid caching too early. Before you add caching, measure your performance. Use the Rails log to see which queries are slow. Use rack-mini-profiler and bullet gem. Cache only the expensive parts. Premature caching can hide bugs and make the code harder to maintain.

Fifth, use conditional get with smart keys. For example, if your article show page includes related articles, include the related articles’ updated_at in the cache key. That way, if a related article changes, the cache for the main article is invalidated.

<% cache [article, article.related_articles.maximum(:updated_at)] do %>

Sixth, consider using cache_digests gem (integrated into Rails 4+). It automatically computes cache key digests from template dependencies. It is magic until it is not. I disable it for complex nested structures because it can cause unexpected invalidations.

Seventh, test your caching in development. I set config.action_controller.perform_caching = true in development.rb when I work on caching features. Otherwise, you will never see if your caches work until you deploy.

Eighth, beware of stale fragments. If you cache a partial that includes a dynamic element like current_user.name, you will serve the wrong name to other users. Always make cache keys user-specific when needed:

<% cache ['sidebar', current_user.id] do %>
  <%= render 'user_sidebar' %>
<% end %>

Ninth, use the cache helper with collections. Rails has a cache helper that you can pass a collection to:

<%= cache @articles do %>
  <%= render @articles %>
<% end %>

This is a shortcut for Russian doll caching. It uses the collection’s cache key and renders each item with its own cache inside. I use this for lists that do not need custom keys.

Tenth, clean your cache store periodically. Redis can grow huge if you never expire old keys. Set maxmemory-policy allkeys-lru in your Redis config to automatically evict least recently used keys.

Let me show you a real-world example from an e-commerce app I worked on. The product page had to display the product details, reviews, and recommendations. Without caching, it was generating 20 queries per page. With fragment caching, I reduced it to 2 queries.

First, I cached the product details:

<% cache [@product, 'details'] do %>
  <%= render 'product_details', product: @product %>
<% end %>

Then I cached the reviews section, keyed by the last review timestamp:

<% cache ['reviews', @product.reviews.maximum(:updated_at)] do %>
  <%= render 'reviews', reviews: @product.reviews.includes(:user) %>
<% end %>

And the recommendations, which changed less often:

<% cache ['recommendations', @product.category, @product.updated_at] do %>
  <%= render 'recommendations', products: Product.recommended_for(@product) %>
<% end %>

The page went from 1.2 seconds to 0.15 seconds. The hit ratio on Redis was 95%.

One more thing: when you cache, you are trading memory for speed. Make sure your cache store has enough RAM. If you run out, Redis starts evicting. I had a production incident where a memory leak filled Redis and all caches disappeared. The app crashed under load. I fixed it by limiting the cache store size and setting TTL on everything.

I have also used a pattern where I cache API responses from external services. For example, weather data that updates hourly:

def weather_for_city(city)
  Rails.cache.fetch("weather/#{city}", expires_in: 30.minutes) do
    WeatherService.fetch(city)
  end
end

This saved me from hitting the weather API thousands of times per day.

Carrying over what I have learned, I now have a mental checklist before adding any cache:

  • What data can I cache? (static, rarely changed, computed)
  • What key should I use? (include version, model timestamps, locale, user id if needed)
  • How long should it live? (TTL based on how often data changes)
  • How do I expire it? (automatic via keys, manual via sweeper, or TTL)
  • What happens if the cache is empty? (fallback to slow code, but that is okay)

I keep a Caching module in app/models/concerns/ for reusable cache methods:

module Caching
  extend ActiveSupport::Concern

  def cache_key_with_version(version = 'v1')
    [version, cache_key]
  end

  module ClassMethods
    def cached_find(id)
      Rails.cache.fetch("#{name}/#{id}", expires_in: 1.hour) do
        find(id)
      end
    end
  end
end

class Product < ApplicationRecord
  include Caching
end

Now Product.cached_find(1) returns from cache if available.

Caching is not a silver bullet. It adds complexity. You need to think about invalidation, storage, and concurrency. But without it, your app will struggle under load. Start with fragment caching. It is the safest and most visible improvement. Then add low-level caching for computations. Use HTTP caching for public pages. And always monitor your metrics.

I still remember the night I pushed that first caching fix. The logs went from query chaos to peaceful silence. The response times dropped. The users stopped complaining. I slept better. That is the power of knowing these seven patterns. Now you know them too. Go use them.


// Keep Reading

Similar Articles