We've debugged Rails apps where adding one cache do block dropped P95 latency from 800ms to 80ms. We've also debugged Rails apps where caching was the bug — stale prices served for hours, user A seeing user B's dashboard, fragments rendering as a blank string after a deploy. Caching is the highest-leverage and highest-risk thing you can add to a Rails app.
This is the playbook we use on production Rails apps in 2026. Specifically: which patterns to use, which cache store to pick, and the invalidation traps that bite every team eventually.
Which Rails caching strategy should you use? (the short answer)
For most Rails apps, use Russian doll fragment caching in views with cache keys derived from updated_at, backed by Solid Cache (the Rails 8 default, database-backed, runs on your existing Postgres). Add Rails.cache.fetch low-level caching for expensive computed values. Reach for Redis only when you need pub/sub or sub-millisecond hot-key latency.
Watch first: Solid Cache in Rails 8
Before the patterns, this is the 12-minute "Rails 8 Unpacked" walkthrough of Solid Cache — how it's different from Redis/Memcached, what NVMe SSD latency looks like in practice, and the one config flag that swaps it in. Watch this if your Rails 8 app is still hauling Redis around for caching only.
The five caching layers in Rails
Rails offers five distinct caching layers. They stack — you can use all five at once — and most production apps use three or four.
| Layer | What it caches | Use when |
|---|---|---|
| HTTP / CDN | Whole responses, by URL | Public, anonymous pages (marketing, blog, public profiles) |
| Page caching (deprecated) | Full HTML to disk | Don't. Removed in Rails 4, gem-only since. |
| Action caching (deprecated) | Controller action output | Don't. Same reason. |
| Fragment caching | Pieces of views | 90% of caching opportunities. Default choice. |
Low-level (Rails.cache.fetch) | Arbitrary objects (counts, computed values, API responses) | Expensive queries, external API responses, computed aggregates |
HTTP/CDN caching is upstream of Rails entirely — it's Cloudflare, Fastly, or nginx serving cached responses without touching the app. The other three live inside Rails. In a typical SaaS, you'll use HTTP caching for marketing pages, fragment caching for authenticated dashboards, and Rails.cache.fetch for the long-tail of computed values.
Fragment caching — the workhorse
Fragment caching wraps a piece of view logic in a cache block. Rails generates a cache key from the model passed in and stores the rendered HTML against it. On the next request, Rails serves the stored HTML without re-rendering.
<% @products.each do |product| %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
The cache key Rails generates here looks like views/products/42-20260603204015000000/abc123def. Two things to notice:
- The model's
updated_atis in the key. Touch the model, the key changes, the cache misses, the fragment regenerates. No manual invalidation. - The template digest is in the key. Edit the template, deploy, the digest changes, the cache misses. No stale templates after deploys.
This is why fragment caching "just works" in Rails. The framework removes the two hardest parts of caching — invalidation and template-change handling — automatically.
Russian doll caching — fragments inside fragments
The pattern that makes fragment caching scale to complex views. Cache the outer container, cache each item inside it, and let Rails reuse the inner caches when only one item changes.
<% cache @category do %>
<h2><%= @category.name %></h2>
<% @category.products.each do |product| %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
<% end %>
When product 42 updates, only product 42's inner cache busts. The outer category cache also busts (because the inner key embeds in the outer cache key), but it regenerates by reading the other 99 product fragments from cache. Net work: re-render one product, re-render the outer wrapper. Skip 99 product re-renders.
Two requirements to make this work:
touch: trueon the association. When a product updates, you need its category to also touch itsupdated_atso the outer cache key changes:class Product < ApplicationRecord belongs_to :category, touch: true end- Render partials with the
collection:form when caching a list. Rails has acached: trueoption that batch-fetches all keys in one cache lookup instead of N:<%= render partial: "product", collection: @products, cached: true %>
The collection form with cached: true is the single biggest performance win in fragment caching. It turns N cache reads into one read_multi — devastating for Memcached/Redis hot loops, also a meaningful win for Solid Cache.
Low-level caching with Rails.cache.fetch
For everything that isn't a view fragment — expensive queries, external API responses, computed aggregates, feature-flag lookups — use Rails.cache.fetch directly.
def trending_products
Rails.cache.fetch("trending_products/v2", expires_in: 10.minutes) do
Product.joins(:orders)
.where(orders: { created_at: 24.hours.ago.. })
.group(:id)
.order("COUNT(orders.id) DESC")
.limit(10)
.to_a
end
end
Three rules we follow on every project:
- Version your keys explicitly (
/v2). When the computation changes, bump the version. Forgetting this is how you serve stale data for a week after a deploy. - Always set
expires_inas a safety net even when you have explicit invalidation. The cache invalidation bug you haven't found yet caps out at this duration. - Cache the smallest unit that makes sense. Don't cache the whole controller action's data — cache the specific expensive bit. Easier to invalidate, easier to reason about.
For a deeper view of the patterns that pair with caching — eager loading, query batching, index usage — see our writeup on Active Record patterns. The fastest cache is the query you didn't need to run.
Picking a cache store in 2026
Until Rails 8, the de-facto choice was Redis (with Memcached as the older alternative). Rails 8 ships Solid Cache as the default — one of several "Solid" defaults we covered in our Rails 8 walkthrough — and it changes the calculation for most apps.
| Cache store | Latency (typical) | Capacity | Use when |
|---|---|---|---|
| Solid Cache | 1-3ms (Postgres on SSD) | 100s of GB easily | Default. Removes Redis from your stack. |
| Redis | 0.3-1ms | Limited by RAM | You need pub/sub, sub-ms latency, or already run Redis for jobs |
| Memcached | 0.5-1ms | Limited by RAM | Multi-server clusters that need to share cache and have no DB headroom |
| :memory_store | 0.01ms | Single-process | Development, tests, single-server apps where cache loss on restart is fine |
The 2-3ms penalty from Solid Cache vs Redis sounds bad until you realize: a typical request that benefits from caching is reducing a 50-200ms operation to 5ms. Whether the 5ms is 1ms (Redis) or 3ms (Solid Cache) is rounding error. What matters is removing the 50-200ms.
Where Solid Cache wins big: capacity. Redis on a typical 8GB instance gets evictions constantly on busy apps. Solid Cache on a Postgres instance with 100GB of disk holds your entire cache forever. Hit rates go up because nothing gets evicted, which usually outweighs the latency penalty entirely.
Our recommendation in 2026: start with Solid Cache. Move to Redis only when you have a specific reason — pub/sub for ActionCable, jobs (Sidekiq), or a measured hot-key path that needs sub-ms.
The cache invalidation problem (and how Rails sidesteps it)
The classic line — there are only two hard things in computer science, cache invalidation and naming things. Rails' approach: don't invalidate. Generate cache keys that change automatically when the underlying data changes, and let old cache entries expire on their own.
This is why cache product works without an explicit "bust this cache key when product updates" step. The cache key contains product.updated_at. Update the product, the timestamp changes, the key changes, the next read misses, the fragment regenerates. The old fragment sits unused until expires_in or eviction.
Where teams break this pattern:
- Caching aggregates without versioning.
Rails.cache.fetch("total_orders")with no version, no expiry, no invalidation. This will serve stale data forever. Add anexpires_inat minimum. - Forgetting
touch: true. Russian doll caching fails silently if the parent doesn't touch when the child updates. You'll see "the cache is just broken" — actually the cache is working, the key just doesn't change. - Manual invalidation. Calling
Rails.cache.deletein 15 places across the codebase. We've debugged this — find-replace as a caching strategy. Always prefer key-based invalidation.
Production scenarios where caching saved (or killed) the app
RankLoop dashboard rendering
On RankLoop, the customer dashboard renders a list of tracked keywords per project with current ranking, change indicators, and a sparkline. Pre-caching: 1.2s P95 for a customer with 200 keywords. After Russian doll caching the keyword list with cached: true collection rendering: 85ms P95. Same data, same query patterns — fragment caching did the work. We covered the broader latency engineering picture in our Rails performance optimization writeup.
The stale-pricing incident
A marketplace we audited cached the home page product list with Rails.cache.fetch("homepage_products", expires_in: 1.hour). Reasonable on day one. Six months later, a price update on a featured product took 30+ minutes to propagate to the home page. Customer support tickets piled up. The fix wasn't shorter expiry — it was using a fragment cache keyed on the products' updated_at so price changes invalidated automatically.
The Redis eviction cascade
A SaaS app on Heroku with Redis Premium (1GB) for cache + Sidekiq + ActionCable. Cache pressure during peak hours caused evictions, including the Sidekiq queue. Jobs silently disappeared. Three days to root-cause. The fix: separate Sidekiq onto its own Redis, move cache to Solid Cache, save $50/mo and never debug eviction again.
Deployment + cache interaction
Two things to know about caching across deploys:
- Fragment cache keys include the template digest, so deploying a template change automatically misses old caches. No manual cache-clear needed after deploys for fragment caching.
- Low-level
Rails.cache.fetchkeys do NOT include template digests. If a deploy changes the computation, the cache will serve old computed values until expiry. Version the key (/v2) when computation changes.
If you're deploying with zero-downtime migrations (and you should be — see our zero-downtime Rails migrations playbook), the same care applies to cache keys. Schema changes that affect cached objects mean either a key version bump or accepting a cache miss spike.
External references
- Caching with Rails — official Rails Guides — the canonical reference, kept current with each Rails release.
- Solid Cache on GitHub — the README is the best Solid Cache reference; covers configuration, encryption, and Postgres tuning.
- Speedshop — The Complete Guide to Rails Caching — Nate Berkopec's deep dive. Older than Solid Cache but the patterns still apply.
FAQ: Rails caching strategies
Should I use Solid Cache or Redis for Rails caching in 2026?
Use Solid Cache as the default. It removes Redis from your infrastructure, gives effectively unlimited capacity on the database disk, and the 2-3ms latency vs Redis is negligible against the 50-200ms operations you're actually caching. Stick with Redis only if you're already using it for Sidekiq/ActionCable, or if you need sub-ms cache reads.
What's the difference between fragment caching and Russian doll caching?
Russian doll caching is fragment caching with nested cache blocks. Fragment caching alone caches one piece of a view. Russian doll caching caches an outer container and each inner item, so when one item updates, only that item regenerates and the outer container reuses the unchanged inner caches.
Do I need to manually invalidate Rails fragment caches?
No, and you shouldn't. Rails generates cache keys that include the model's updated_at timestamp and the template digest. Updating the model changes the key automatically; editing the template changes the digest automatically. Manual invalidation via Rails.cache.delete is an anti-pattern in fragment-cached views.
How do I cache an expensive query result?
Wrap it in Rails.cache.fetch with an explicit version in the key and a defensive expires_in: Rails.cache.fetch("trending_products/v2", expires_in: 10.minutes) { Product.expensive_query.to_a }. Bump the version when the computation changes. The expires_in is your safety net for the invalidation bug you haven't found.
Does Rails caching work the same on Rails 7 and Rails 8?
The caching API is identical. Rails.cache.fetch, cache view helpers, fragment caching — all unchanged. The difference is the default store: Rails 7 defaults to :memory_store, Rails 8 defaults to Solid Cache. Switching cache stores requires only config changes; application code is portable.
How we can help
At TechVinta, caching is a default part of our Rails performance work — we'll typically audit cache hit rates, key patterns, and store choice as part of any performance engagement. Most Rails apps we audit have 2-3 obvious caching wins worth 5-10x latency improvements and a handful of stale-cache bugs hiding in Rails.cache.fetch calls without expiry.
Rails app slow under load, or unsure whether to migrate to Solid Cache? Talk to our Rails performance team or get a free estimate — we'll review your caching layer and propose a plan within 48 hours.