Rails 8 Caching with Solid Cache

Rails 8 introduced Solid Cache, a database-backed caching solution that eliminates the need for Redis or Memcached in many applications. This simplifies infrastructure, reduces costs, and leverages the durability of MySQL for cache storage.

Why Database-Backed Caching?

Traditional caching solutions like Redis add operational complexity: another service to monitor, scale, and maintain. Solid Cache stores cache entries directly in MySQL, which most Rails applications already use. Modern SSDs make database-backed caching surprisingly fast, and MySQL's reliability means cache data survives restarts.

Solid Cache works best for applications where cache hits don't require sub-millisecond latency. For most web applications—caching fragments, API responses, or computed values—MySQL performs exceptionally well.

Setting Up Solid Cache

Rails 8 applications created with the default configuration include Solid Cache automatically. For existing applications, add the gem and run the installer:

# Gemfile
gem "solid_cache"

# Terminal
$ bundle install
$ bin/rails solid_cache:install

The installer creates a migration for the cache table and updates the configuration. Run the migration to create the necessary table:

# db/migrate/20260106000000_create_solid_cache_entries.rb
class CreateSolidCacheEntries < ActiveRecord::Migration[8.0]
  def change
    create_table :solid_cache_entries do |t|
      t.binary :key, null: false, limit: 1024
      t.binary :value, null: false, limit: 536870912
      t.datetime :created_at, null: false

      t.index :key, unique: true
      t.index :created_at
    end
  end
end

Configure the cache store in the environment file:

# config/environments/production.rb
Rails.application.configure do
  config.cache_store = :solid_cache_store
end

Configuring Cache Behavior

Solid Cache offers several configuration options for controlling cache size and cleanup. Create a dedicated configuration file:

# config/solid_cache.yml
default: &default
  database: cache
  store_options:
    max_age: <%= 1.week.to_i %>
    max_size: <%= 256.megabytes %>
    namespace: <%= Rails.env %>

development:
  <<: *default
  store_options:
    max_size: <%= 64.megabytes %>

production:
  <<: *default
  store_options:
    max_size: <%= 1.gigabyte %>
    max_age: <%= 2.weeks.to_i %>

For larger applications, Solid Cache supports a dedicated database connection. This prevents cache operations from competing with primary database queries:

# config/database.yml
production:
  primary:
    adapter: mysql2
    database: myapp_production
    host: <%= ENV["DB_HOST"] %>
  cache:
    adapter: mysql2
    database: myapp_cache_production
    host: <%= ENV["CACHE_DB_HOST"] %>
    migrations_paths: db/cache_migrate

Practical Caching Patterns

Fragment caching with Solid Cache works identically to other cache stores. The performance characteristics favor caching larger fragments rather than many small ones:

# app/views/dashboard/show.html.erb
<%% cache ["dashboard", current_user, current_user.projects.maximum(:updated_at)] do %>
  

Active Projects

<%%= current_user.projects.active.count %>

Pending Tasks

<%%= current_user.tasks.pending.count %>

<%% current_user.projects.active.each do |project| %> <%%= render project %> <%% end %>
<%% end %>

Low-level caching handles computed values and API responses effectively. Use fetch blocks to compute values only when the cache misses:

# app/models/report.rb
class Report < ApplicationRecord
  belongs_to :organization

  def summary_statistics
    Rails.cache.fetch(cache_key_for_statistics, expires_in: 1.hour) do
      {
        total_revenue: calculate_revenue,
        growth_rate: calculate_growth_rate,
        top_products: fetch_top_products,
        generated_at: Time.current
      }
    end
  end

  def invalidate_statistics_cache
    Rails.cache.delete(cache_key_for_statistics)
  end

  private

  def cache_key_for_statistics
    ["report_statistics", id, updated_at.to_i]
  end

  def calculate_revenue
    line_items.sum(:amount)
  end

  def calculate_growth_rate
    # Complex calculation
  end

  def fetch_top_products
    line_items.group(:product_id)
              .order("SUM(amount) DESC")
              .limit(10)
              .pluck(:product_id)
  end
end

Cache Expiration Strategies

Solid Cache automatically evicts entries based on age and size limits. However, explicit invalidation ensures data freshness. Use callbacks to clear related caches:

# app/models/product.rb
class Product < ApplicationRecord
  belongs_to :category
  after_commit :invalidate_caches

  private

  def invalidate_caches
    Rails.cache.delete(["product", id])
    Rails.cache.delete(["category_products", category_id])
    Rails.cache.delete_matched("products_list_*")
  end
end

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Rails.cache.fetch(
      ["products_list", params[:page], params[:category]],
      expires_in: 15.minutes
    ) do
      Product.includes(:category)
             .where(category_id: params[:category])
             .page(params[:page])
             .to_a
    end
  end
end

Monitoring and Maintenance

Monitor cache table size and hit rates to optimize configuration. Create a simple health check endpoint:

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  def cache_status
    stats = {
      entries_count: SolidCache::Entry.count,
      oldest_entry: SolidCache::Entry.minimum(:created_at),
      newest_entry: SolidCache::Entry.maximum(:created_at),
      estimated_size: SolidCache::Entry.sum("LENGTH(value)")
    }

    render json: stats
  end
end

For MySQL, ensure the cache table uses appropriate settings for write-heavy workloads:

# db/migrate/20260106000001_optimize_solid_cache_table.rb
class OptimizeSolidCacheTable < ActiveRecord::Migration[8.0]
  def up
    execute "ALTER TABLE solid_cache_entries ROW_FORMAT=COMPRESSED"
  end

  def down
    execute "ALTER TABLE solid_cache_entries ROW_FORMAT=DEFAULT"
  end
end

Common Mistakes to Avoid

Caching database queries directly returns stale Active Record objects. Always convert to plain data structures:

# Wrong: Caches Active Record objects
Rails.cache.fetch("users") { User.all }

# Correct: Caches serializable data
Rails.cache.fetch("users") { User.all.to_a }

# Better: Cache only what's needed
Rails.cache.fetch("user_names") { User.pluck(:id, :name).to_h }

Avoid cache keys that change too frequently. Include only the attributes that affect the cached content:

# Too volatile: updated_at changes on every save
cache_key = ["user", user.id, user.updated_at]

# More stable: version only relevant attributes
cache_key = ["user_profile", user.id, user.avatar_updated_at, user.bio_updated_at]

Summary

Solid Cache provides a production-ready caching solution without external dependencies. The setup process involves adding the gem, running migrations, and configuring the cache store. For most applications, the default settings work well, with optional tuning for cache size and expiration policies.

Consider a dedicated cache database for high-traffic applications, and monitor cache table growth to adjust size limits. Combined with proper invalidation strategies, Solid Cache delivers reliable caching backed by MySQL's durability.

11 claps
← Back to Blog