The caching problem at scale
By day 35, most Rails teams hit the same wall: pages that used to load in 50ms now crawl at 2–3 seconds. Database queries multiply, view rendering bogs down, and suddenly your background jobs can't keep up. Caching in Rails: Ruby on Rails best practices explained isn't about adding Redis and calling it done—it's about understanding where bottlenecks live and applying the right tool at each layer.
We've seen this pattern across SaaS platforms, e-commerce sites, and CRM dashboards. The good news: Rails gives us multiple caching strategies that compound. Used correctly, fragment caching, Russian doll caching, low-level Rails.cache calls, and HTTP ETags can cut response times by 80–90% without rewriting your application logic.
Fragment caching — Cache blocks in views
Fragment caching is your first line of defense. It wraps expensive view logic—rendering lists, formatted dates, computed display values—so Rails skips re-rendering on every request.
Say you have a dashboard that lists user orders with formatted timestamps and computed totals:
<% @user.orders.each do |order| %>
<div class="order-card">
<p><%= order.created_at.strftime('%B %d, %Y') %></p>
<p>Total: $<%= order.line_items.sum(:price) %></p>
</div>
<% end %>Without caching, this runs the strftime and sum queries on every page load. With fragment caching, you wrap the block:
<% @user.orders.each do |order| %>
<% cache(["order", order.id, order.updated_at]) do %>
<div class="order-card">
<p><%= order.created_at.strftime('%B %d, %Y') %></p>
<p>Total: $<%= order.line_items.sum(:price) %></p>
</div>
<% end %>
<% end %>Rails creates a cache key from the array: order/123/2024-01-15-14-32-00. On the first request, it renders and stores the HTML fragment. On subsequent requests, it pulls the HTML directly from cache. When order.updated_at changes, the key changes and the cache misses—Rails re-renders automatically.
Fragment caching works best for:
- Sidebar or header widgets that appear on many pages.
- Individual item rendering inside loops.
- Expensive computed display values (price calculations, formatting).
One important detail: fragment caching only skips view rendering. Your controller still runs, your model queries still fire unless you optimize them separately. Use fragment caching alongside query optimization for best results.
Russian doll caching — Nested caches with touch: true
Russian doll caching (nested fragment caching) takes fragment caching further. You cache smaller fragments inside larger ones, and when an inner fragment invalidates, the outer one does too.
Imagine a user dashboard showing multiple projects, each with tasks:
<% cache(["user-dashboard", @user.id, @user.updated_at]) do %>
<div class="dashboard">
<h1><%= @user.name %></h1>
<% @user.projects.each do |project| %>
<% cache(["project", project.id, project.updated_at]) do %>
<div class="project-card">
<h2><%= project.title %></h2>
<% project.tasks.each do |task| %>
<% cache(["task", task.id, task.updated_at]) do %>
<div class="task">
<p><%= task.name %> — <%= task.status %></p>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>Now, when a task updates, only its fragment re-renders. But here's the catch: the project and user fragments stay cached even though their content changed. To fix this, use touch: true in your models:
class Task < ApplicationRecord
belongs_to :project, touch: true
end
class Project < ApplicationRecord
belongs_to :user, touch: true
endWhen a task updates, touch: true automatically updates the updated_at timestamp on its parent project. The project's cache key changes, and the outer fragment re-renders. The user's updated_at updates too, invalidating the entire dashboard cache. This cascade ensures your nested caches stay consistent.
Russian doll caching works beautifully for:
- Hierarchical content (users → projects → tasks).
- Partially mutable data (new task added, project title stays the same).
- Pages with many re-usable sub-components.
The mental model: cache at every level, but let the cache keys propagate upward through relationships. Only render what changed.
Low-level caching — Rails.cache.fetch for expensive work
Fragment and Russian doll caching work in views. For expensive computations—statistics, aggregations, API calls—use low-level caching with Rails.cache.fetch.
Say you compute user statistics on a dashboard: total orders, average order value, lifetime spend:
class UsersController < ApplicationController
def dashboard
@stats = Rails.cache.fetch(["stats", @user.id, Date.current], expires_in: 1.hour) do
{
total_orders: @user.orders.count,
avg_order_value: @user.orders.average(:total).round(2),
lifetime_spend: @user.orders.sum(:total).round(2)
}
end
end
endOn the first call, Rails computes all three values and stores the result under the key stats/user-id/2024-01-15 with a 1-hour expiration. On subsequent calls within that hour, it returns the cached hash directly. After 1 hour, the cache expires and Rails recomputes.
When stats depend on data changes rather than time, use conditional expiration:
def invalidate_stats
Rails.cache.delete(["stats", @user.id, Date.current])
redirect_to dashboard_path
endOr let model callbacks handle it:
class Order < ApplicationRecord
belongs_to :user
after_commit do
Rails.cache.delete(["stats", user_id, Date.current])
end
endNow every time an order is created or updated, its user's cached stats expire. On the next dashboard load, fresh stats are computed.
Low-level caching works best for:
- Expensive database aggregations (sums, averages, counts across millions of rows).
- External API calls (weather, pricing, third-party data).
- Complex computations (machine learning models, financial calculations).
- Data that changes infrequently or on a predictable schedule.
A practical tip: always set expires_in. Unbounded caches can hide stale data bugs for weeks.
HTTP caching — fresh_when and stale? for ETags
Rails gives you one more layer: HTTP caching. Instead of storing HTML on the server, you tell the browser or CDN "this content hasn't changed." The client re-uses its local copy, and you save bandwidth and server load.
Rails generates ETags (entity tags) automatically for most responses. When you render a view, Rails hashes the content and sends the hash in an HTTP header. On the next request, the browser sends that hash back. If it matches, Rails returns a 304 Not Modified response and the browser uses its cached version.
You can optimize this explicitly with fresh_when:
class OrdersController < ApplicationController
def show
@order = Order.find(params[:id])
fresh_when(@order)
end
endRails computes an ETag based on @order.updated_at and sends it to the browser. On repeat visits within a few seconds or minutes, the browser sends the same ETag. Rails sees it matches and returns 304—no view rendering, no database query, no bandwidth. The browser displays its cached version instantly.
For more control, use stale?:
class OrdersController < ApplicationController
def show
@order = Order.find(params[:id])
if stale?(@order, public: true)
respond_to do |format|
format.html { render }
format.json { render json: @order }
end
end
end
endThe public: true flag tells caches (browsers, proxies, CDNs) they can cache the response for any user. Without it, only private browser caches store it. If the content is user-specific, omit public.
HTTP caching is incredibly efficient for:
- Public pages (blog posts, product pages).
- User-specific pages that change infrequently (profiles, settings).
- Reducing load on your origin server when fronted by a CDN.
- Speeding up subsequent requests from the same user or device.
The downside: clients control whether they revalidate. Some browsers respect ETags religiously; others ignore them. Combine HTTP caching with server-side caching for predictability.
Common mistakes
Caching before optimization: Fragment caching won't save you if your N+1 query executes inside the cached block. Optimize your queries first, cache second.
Forgetting cache invalidation: A cached fragment stays stale forever if you don't invalidate it. Always think through when cached data should expire—use expires_in, model callbacks, or manual invalidation. Stale data causes more bugs than slow pages.
Caching at the wrong layer: Don't cache static HTML fragments that never change. Don't use Rails.cache.fetch for view rendering when fragment caching is cleaner. Match the tool to the problem.
Ignoring cache memory: In-memory caches (Rails default, MemoryStore) don't scale across multiple servers. In production, use Redis or Memcached. Otherwise, each server maintains its own cache and you lose consistency.
Over-nesting Russian doll caches: Three or four levels is normal. Beyond that, debugging becomes nightmare and invalidation cascades unpredictably. Keep nesting shallow.
Key takeaways
- Fragment caching wraps view blocks and skips re-rendering when cache keys match. Include model IDs and
updated_atin your keys. - Russian doll caching nests fragments and uses
touch: trueon associations to propagate invalidation up the hierarchy. - Low-level caching with
Rails.cache.fetchhandles expensive computations, API calls, and aggregations. Always setexpires_in. - HTTP caching with
fresh_whenandstale?reduces bandwidth and server load by letting browsers cache responses and revalidate with ETags. - Caching compounds: use all four layers together. Optimize queries, then cache views, then cache expensive logic, then cache HTTP responses.
- Invalidation is harder than caching. Think through your cache lifetime strategy before you write code.
- In production, use Redis or Memcached instead of the default in-memory store for consistency across multiple servers.






Comments
Loading comments…