Omaship

March 20, 2026 . 13 min read

Rails 8 Caching and Performance for SaaS in 2026: Solid Cache, Fragment Caching, and the Patterns That Actually Scale

Jeronim Morina

Jeronim Morina

Founder, Omaship

Your SaaS dashboard loads in 1.2 seconds. Your competitor's loads in 200 milliseconds. Your users do not write blog posts about it -- they just leave. Caching is not a premature optimization for SaaS. It is the difference between an app that feels fast and one that feels broken.

Rails has always had strong caching primitives, but Rails 8 changed the game. Solid Cache replaces Redis and Memcached with your database. Fragment caching and Russian doll caching are built into the view layer. HTTP caching headers let you skip entire request cycles. And yet most Rails SaaS apps barely use any of it.

This guide covers every caching layer available to you in Rails 8, from the database to the CDN edge. No toy examples. Real patterns for real SaaS products with real users who notice when things are slow.

Why caching is non-negotiable for SaaS

Amazon found that every 100ms of latency cost them 1% in sales. Google found that an extra 500ms in search page generation dropped traffic by 20%. Your SaaS is not Amazon, but the psychology is identical: latency erodes trust.

SaaS products have a specific problem that content sites do not: dashboards. A blog post renders once and gets cached globally. A SaaS dashboard renders differently for every user, pulls data from multiple tables, computes aggregates, and formats everything into charts and tables. Without caching, every page load re-executes all of that work.

  • Churn correlation: Users who experience slow load times in the first week have 2-3x higher churn rates. They do not file support tickets. They just stop logging in.
  • Perceived quality: A fast app feels well-built, even if the feature set is smaller. A slow app feels abandoned, even if it has every feature a user could want.
  • Server costs: Caching reduces compute. A well-cached Rails app on a $20/month server handles the same traffic as an uncached app on a $200/month server.

The good news: Rails gives you caching at every layer of the stack. The bad news: most tutorials only cover one or two layers. Here is all of them.

The Solid Cache revolution: no more Redis for caching

Before Rails 8, caching meant running a separate service. Redis or Memcached sat alongside your app, required its own monitoring, its own memory limits, its own failure modes. For a solo founder running a SaaS, that is operational overhead you do not need.

Solid Cache stores cache entries in your database. That sounds slow until you realize two things: modern SSDs make database reads fast enough for cache workloads, and you already operate a database. No new infrastructure. No new failure modes. No Redis connection pool tuning at 2 AM.

# config/environments/production.rb
config.cache_store = :solid_cache_store

# config/cache.yml
production:
  database: cache
  max_age: 5184000
  max_size: 268435456

That is the entire setup. Rails 8 generates the cache database configuration automatically. Solid Cache handles expiration, size limits, and cleanup in the background. Cache entries are stored as key-value rows with automatic LRU eviction when the size limit is reached.

When Solid Cache is the right choice

  • Solo founders and small teams: One less service to operate. Your database backup now includes your cache warm-up data for free.
  • SQLite deployments: If you are running Rails 8 with SQLite (which is now a legitimate production choice), Solid Cache keeps your entire stack on disk with zero external dependencies.
  • Moderate traffic: Up to thousands of requests per second. Solid Cache handles this comfortably on modern hardware.

When to keep Redis

If your cache hit rate needs sub-millisecond reads (high-frequency trading dashboards, real-time multiplayer), Redis is still faster for pure in-memory workloads. But for 99% of SaaS applications, the difference between a 1ms Redis read and a 3ms Solid Cache read is invisible to users.

Fragment caching: the workhorse of Rails view performance

Fragment caching wraps a piece of your view in a cache block. The first render executes the code and stores the HTML. Subsequent renders skip the code entirely and serve the stored HTML. This is where most Rails SaaS apps get their biggest performance wins.

<%# app/views/projects/show.html.erb %>
<% cache @project do %>
  <div class="project-header">
    <h1><%= @project.name %></h1>
    <p><%= @project.description %></p>
    <span>Created <%= time_ago_in_words(@project.created_at) %> ago</span>
  </div>
<% end %>

The cache key is derived from the model's cache_key_with_version, which includes the updated_at timestamp. When the project changes, the cache key changes, and Rails generates fresh HTML on the next request. Old cache entries are evicted automatically.

Russian doll caching: nested fragments that expire independently

Russian doll caching is fragment caching applied recursively. An outer cache wraps inner caches. When an inner model changes, only the inner fragment and its parents are regenerated. Everything else stays cached.

<%# app/views/dashboards/show.html.erb %>
<% cache ["dashboard-v1", current_user] do %>
  <h1>Your Projects</h1>

  <% @projects.each do |project| %>
    <% cache project do %>
      <div class="project-card">
        <h2><%= project.name %></h2>
        <p><%= project.status %></p>

        <% project.recent_deploys.each do |deploy| %>
          <% cache deploy do %>
            <div class="deploy-row">
              <%= deploy.version %> - <%= deploy.deployed_at.strftime("%b %-d") %>
            </div>
          <% end %>
        <% end %>
      </div>
    <% end %>
  <% end %>
<% end %>

The key to making Russian doll caching work is touch: true on your associations:

# app/models/deploy.rb
class Deploy < ApplicationRecord
  belongs_to :project, touch: true
end

# app/models/project.rb
class Project < ApplicationRecord
  belongs_to :user, touch: true
  has_many :deploys, -> { order(deployed_at: :desc) }
end

When a deploy is created or updated, it touches the project, which touches the user. The cache keys for all three levels become stale, and Rails regenerates the affected fragments on the next request. Fragments for other projects remain cached.

Collection caching: the fast path for lists

Rendering a collection of partials is one of the most common patterns in SaaS views. Rails can cache each partial individually and fetch them all in a single multi-read:

<%= render partial: "projects/project", collection: @projects, cached: true %>

With cached: true, Rails reads all cache keys in one batch. Cache hits are served from the store. Cache misses are rendered and written back. For a dashboard showing 50 projects where only 2 changed since the last request, Rails renders 2 partials and serves 48 from cache.

Low-level caching: Rails.cache.fetch patterns

Fragment caching handles views. Low-level caching handles everything else -- expensive computations, external API responses, aggregated metrics, and any data that is slow to generate and safe to serve slightly stale.

# app/models/project.rb
class Project < ApplicationRecord
  def monthly_deploy_count
    Rails.cache.fetch([cache_key_with_version, "monthly_deploys"], expires_in: 1.hour) do
      deploys.where(deployed_at: 30.days.ago..).count
    end
  end
end

The block only executes on a cache miss. The cache key includes the model's version, so when the project is updated, the cached count is automatically invalidated. The expires_in is a safety net -- even if the model is not touched, the count refreshes every hour.

Caching expensive dashboard aggregates

SaaS dashboards love aggregate queries. Total revenue this month. Active users this week. Error rate in the last hour. These queries hit every row in the table and get slower as your data grows.

# app/models/analytics/dashboard_stats.rb
class Analytics::DashboardStats
  def initialize(team)
    @team = team
  end

  def summary
    Rails.cache.fetch([@team.cache_key_with_version, "dashboard_stats"], expires_in: 5.minutes) do
      {
        total_projects: @team.projects.count,
        active_deploys: @team.deploys.where(status: :active).count,
        error_rate: calculate_error_rate,
        monthly_cost: calculate_monthly_cost
      }
    end
  end

  private

  def calculate_error_rate
    total = @team.deploys.where(deployed_at: 24.hours.ago..).count
    return 0.0 if total.zero?

    errors = @team.deploys.where(deployed_at: 24.hours.ago.., status: :failed).count
    (errors.to_f / total * 100).round(2)
  end

  def calculate_monthly_cost
    @team.invoices.where(period_start: Time.current.beginning_of_month..).sum(:amount_cents) / 100.0
  end
end

Five minutes of staleness is acceptable for a dashboard summary. The tradeoff is explicit: slightly stale numbers in exchange for a dashboard that loads in milliseconds instead of seconds.

Cache warming for critical paths

Some cache entries are too expensive to generate on a user request. Warm them in a background job triggered by after_commit callbacks on the models that change the underlying data. The user always hits a warm cache, and the job runs on Solid Queue with no additional infrastructure.

HTTP caching: skip the entire request

Every caching strategy so far still executes a Rails request. HTTP caching can skip the request entirely. The browser (or a CDN) serves the response from its own cache without ever hitting your server.

ETags and conditional GET with stale?

Rails generates ETags automatically for every response, but you can make them smarter:

# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  def show
    @project = current_user.projects.find(params[:id])

    if stale?(@project)
      respond_to do |format|
        format.html
        format.json { render json: @project }
      end
    end
  end

  def index
    @projects = current_user.projects.order(updated_at: :desc)

    if stale?(etag: @projects, last_modified: @projects.maximum(:updated_at))
      render :index
    end
  end
end

When the browser sends a request with an If-None-Match header matching the current ETag, Rails returns a 304 Not Modified response with no body. The browser renders from its local cache. Zero bandwidth, zero rendering time.

fresh_when for read-heavy pages

For pages that are purely informational and do not need the full response cycle:

# app/controllers/guides_controller.rb
class GuidesController < ApplicationController
  def show
    @article = Guides::ArticleCatalog.find_by_slug(params[:slug])
    fresh_when etag: @article[:slug], last_modified: @article[:published_at].to_time
  end
end

fresh_when sets the ETag and Last-Modified headers and automatically returns 304 if the content has not changed. For static-ish content like guides or settings pages, this eliminates redundant rendering entirely.

Counter caches: stop counting rows on every request

Every SaaS dashboard has counts. "12 projects." "847 deploys." "3 team members." Without counter caches, each count is a SELECT COUNT(*) query that scans the table. On a dashboard with 10 counts, that is 10 queries before you render a single line of HTML.

# db/migrate/20260326_add_counter_caches.rb
class AddCounterCaches < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :projects_count, :integer, default: 0, null: false
    add_column :projects, :deploys_count, :integer, default: 0, null: false
  end
end

# app/models/project.rb
class Project < ApplicationRecord
  belongs_to :user, counter_cache: true
  has_many :deploys
end

# app/models/deploy.rb
class Deploy < ApplicationRecord
  belongs_to :project, counter_cache: true
end

Now user.projects_count is a column read, not a table scan. Rails increments and decrements the counter automatically when records are created or destroyed. Ten count queries become ten column reads from rows you already loaded.

Avoiding N+1 queries in dashboard views

N+1 queries are the silent killer of SaaS performance. Your dashboard lists 25 projects. Each project card shows the latest deploy status. Without eager loading, that is 1 query for projects + 25 queries for deploys = 26 queries for one page.

# Bad: N+1 queries
def index
  @projects = current_user.projects
  # Each project.deploys.last triggers a query in the view
end

# Good: eager load associations
def index
  @projects = current_user.projects
    .includes(:latest_deploy)
    .order(updated_at: :desc)
end

# app/models/project.rb
class Project < ApplicationRecord
  has_one :latest_deploy, -> { order(deployed_at: :desc) }, class_name: "Deploy"
end

includes loads all deploys in a single query. The view iterates without triggering additional database calls. For dashboards, this is often the single biggest performance improvement you can make.

Database query optimization

Caching reduces how often queries run. Query optimization reduces how long they take when they do run. Both matter.

Select only what you need

# Bad: loads every column including large text fields
@projects = current_user.projects

# Good: load only the columns the view needs
@projects = current_user.projects.select(:id, :name, :status, :updated_at, :deploys_count)

If your projects table has a readme column with 50KB of markdown per row, loading 25 projects means transferring 1.25MB from the database -- data the index view never uses. select cuts that to kilobytes.

Use explain to find slow queries

# In rails console
Project.where(user_id: 1).order(updated_at: :desc).explain

# Output shows the query plan:
# QUERY PLAN
# Sort (cost=12.45..12.48 rows=12 width=340)
#   Sort Key: updated_at DESC
#   -> Seq Scan on projects (cost=0.00..12.20 rows=12 width=340)
#         Filter: (user_id = 1)

# Add an index if you see sequential scans on large tables
add_index :projects, [:user_id, :updated_at]

A sequential scan on a table with 1,000 rows is fine. A sequential scan on a table with 1,000,000 rows is a problem. Use explain on any query that appears in your slow query log, and add indexes for the columns in your WHERE and ORDER BY clauses.

CDN and asset caching with Rails 8

Rails 8 with Propshaft handles asset fingerprinting out of the box. Every CSS and JavaScript file gets a content-based hash in its filename. When the content changes, the filename changes. When the filename changes, browsers fetch the new version. When it does not change, browsers serve from their local cache indefinitely.

# config/environments/production.rb

# Serve assets with far-future expires headers
config.public_file_server.headers = {
  "Cache-Control" => "public, max-age=#{1.year.to_i}, immutable"
}

# If using a CDN (CloudFront, Cloudflare, etc.)
config.asset_host = "https://cdn.yourdomain.com"

The immutable directive tells browsers this file will never change at this URL. Combined with a one-year max-age, browsers never re-validate cached assets. This eliminates conditional GET requests for assets entirely.

Page caching for public content

Marketing pages, pricing pages, and documentation do not change per-user. Put a CDN in front of them and cache aggressively:

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  def pricing
    expires_in 1.hour, public: true
    # The CDN caches this response for 1 hour
    # All users in the same region get the cached version
  end
end

For authenticated pages, use private: true to prevent CDN caching while still allowing browser caching:

# app/controllers/dashboards_controller.rb
class DashboardsController < ApplicationController
  def show
    expires_in 30.seconds, private: true
    # Browser can cache, CDN cannot
    # User sees slightly stale data for 30 seconds after changes
  end
end

Monitoring performance: find the slow spots before users do

Caching without monitoring is guessing. You need to know what is slow, what is cached, and what is missing the cache.

rack-mini-profiler: the fastest feedback loop

# Gemfile
gem "rack-mini-profiler", group: :development

# No configuration needed. It just works.
# A speed badge appears in the top-left corner of every page.
# Click it to see:
#   - Total request time
#   - SQL queries (count and duration)
#   - Cache hits and misses
#   - Partial render times

rack-mini-profiler shows you the performance profile of every page load in development. It is the single most effective tool for finding performance problems before they reach production. If you install one gem from this guide, make it this one.

Bullet: automatic N+1 detection

# Gemfile
gem "bullet", group: :development

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true          # Browser popup for N+1 queries
  Bullet.bullet_logger = true  # Log to log/bullet.log
  Bullet.rails_logger = true   # Log to Rails log
  Bullet.add_footer = true     # Show in page footer
end

Bullet watches every query your app makes and alerts you when it detects N+1 queries or unused eager loading. It tells you exactly which association to add to includes and which to remove. Run it in development and your production database will thank you.

Production monitoring

In production, track these metrics:

  • p95 response time: The 95th percentile is what your slowest regular users experience. Averages hide outliers. Track p95 per controller action.
  • Cache hit rate: If your Solid Cache hit rate drops below 90%, you are either caching the wrong things or your cache is too small. Check max_size in your cache configuration.
  • Database query count per request: A healthy dashboard request should execute fewer than 10 queries. If you see 50+, you have N+1 problems or missing caches.
  • Slow query log: Any query taking more than 100ms deserves investigation. Missing indexes, unscoped queries, or table scans on growing tables.

The caching checklist for Rails SaaS

Not every app needs every caching layer. Here is what to implement and when:

  1. Day one: Set up Solid Cache. Add includes for associations loaded in views. Install Bullet in development. This takes 20 minutes and prevents the most common performance problems.
  2. First 100 users: Add fragment caching to your dashboard partials. Add counter caches for counts displayed in the UI. Add select to queries that load large text columns.
  3. First 1,000 users: Implement Russian doll caching for nested views. Add low-level caching for expensive aggregates. Set up HTTP caching for public pages. Add rack-mini-profiler to development.
  4. Scaling beyond: Add a CDN for assets and public pages. Implement cache warming for critical dashboard data. Set up production performance monitoring. Consider background precomputation for the heaviest reports.

Each step builds on the previous one. Do not skip to step four when you have 50 users. The complexity is not worth it yet.

How Omaship ships fast

Omaship starts with Solid Cache configured out of the box. Fragment caching is enabled in production. Asset fingerprinting and far-future cache headers are set. The Solid Trifecta -- Solid Queue, Solid Cache, and Solid Cable -- runs on your database with zero external dependencies.

The dashboard uses eager loading and counter caches by default. N+1 queries are caught by the test suite. Performance monitoring is included through the omaship_monitoring engine. You start with a fast foundation and add caching layers as your traffic demands them.

Ship a SaaS that loads fast from day one.

Omaship includes Solid Cache, fragment caching, eager loading, counter caches, and performance monitoring out of the box. No Redis required. No configuration guesswork.

Start building

Continue reading

We use analytics and session recordings to learn which parts of Omaship help and which need work. Accept all, or customize what you share.

Privacy policy