RUBY ON RAILS · 17 MIN READ ·

Rails 8 Solid Cache: Production Setup, TTL Strategies, and Migrating from Redis

Rails 8 Solid Cache in production: setup guide with database sizing, TTL and eviction strategies, migrating from Redis, and monitoring cache hit rates.

Rails 8 Solid Cache: Production Setup, TTL Strategies, and Migrating from Redis

The Redis instance for one of my clients was costing them $340 a month on ElastiCache and holding roughly 900 MB of cache data. When Rails 8 shipped Solid Cache as the default cache backend, the conversation with their CTO lasted about four minutes: “So we can delete the Redis cluster and keep the cache on the Postgres database we already run?” Yes. And two weeks later that ElastiCache bill went to zero.

Rails 8 Solid Cache is a database-backed cache store that lives in the same relational database you already use for your application. It replaces Redis or Memcached for the majority of Rails applications that were using them purely as a Rails cache — not as a Sidekiq queue, not as a Pub/Sub bus, just as Rails.cache. If Redis was one more piece of infrastructure your ops team maintained for what amounted to a fancy key-value table, Solid Cache lets you delete it.

After nineteen years of running Rails in production, most of the Redis clusters I have seen in Rails apps were doing exactly this: caching fragment renders, memoizing expensive queries, and holding session data. None of it needed sub-millisecond latency. None of it needed the operational overhead of a second data store.

What Rails 8 Solid Cache Actually Is

Solid Cache is a Rails.cache implementation backed by SQL. It stores each cache entry as a row in a table with a byte-array value column, an expiration timestamp, and a hash-based key index. Reads are single-row lookups by primary key. Writes are inserts with ON CONFLICT DO UPDATE. Expiration and eviction happen through a background sweep that runs inside your Rails process — no cron job, no separate worker.

Three things make it viable as a Redis replacement rather than a toy:

  • Encryption at rest — cache values can be encrypted before they hit the disk using ActiveRecord::Encryption. Session tokens and cached user data are protected even if someone gets a database dump.
  • LRU-style eviction with size targets — you tell Solid Cache the maximum number of rows or the maximum byte size, and it evicts the least-recently-touched entries when the target is exceeded. No unbounded growth.
  • Multi-database support — the cache table can live in a separate database from your application data. On production I always put it on its own Postgres instance or its own database on a shared cluster.

The performance ceiling is lower than Redis. A single-row Postgres lookup with a primary key hit takes 0.5–2 ms end-to-end from Rails; the equivalent Redis GET takes 0.2–0.5 ms. For fragment caches, view caches, and memoized query results, the difference is noise. For anything requiring atomic increments a thousand times per second, use Redis.

Setting Up Rails 8 Solid Cache on a New App

Rails 8 ships with Solid Cache configured by default when you generate a new app. The solid_cache gem is in the Gemfile, and config/cache.yml looks like this:

# config/cache.yml
default: &default
  store_options:
    max_age: <%= 60.days.to_i %>
    max_size: <%= 256.megabytes %>
    namespace: <%= Rails.env %>

development:
  <<: *default

test:
  <<: *default

production:
  database: cache
  <<: *default

The database: cache line refers to an entry in config/database.yml. Add a dedicated cache database configuration:

# config/database.yml
production:
  primary:
    <<: *default
    database: myapp_production
  cache:
    <<: *default
    database: myapp_production_cache
    migrations_paths: db/cache_migrate

Create the schema and run the Solid Cache migrations:

bin/rails db:create
bin/rails solid_cache:install:migrations
bin/rails db:migrate:cache

That is the entire setup. Rails.cache.read("key") and Rails.cache.write("key", value, expires_in: 1.hour) now hit the Postgres cache database. No Redis process is running.

Sizing the Cache Database for Production

The single question that determines your Solid Cache setup is: how big should the cache table be allowed to grow? Get this wrong and you either evict productive cache entries constantly or let the cache table balloon and slow down your database backups.

The formula I use for Rails 8 Solid Cache sizing is:

max_size = (avg entry size) × (working set entries) × 1.3 safety factor

For a typical Rails application with page-fragment caching, cached query results, and Russian-doll caching, the average entry size lands around 4–12 KB and the working set is 20,000–100,000 live entries. That gives you a cache size between 100 MB and 1.5 GB. On the client I mentioned earlier, we set max_size: 768.megabytes and the cache stabilized at 620 MB with a 94% hit rate.

The size target is enforced by a background trimmer:

# config/cache.yml — full production config
production:
  database: cache
  store_options:
    max_age: <%= 30.days.to_i %>
    max_size: <%= 768.megabytes %>
    namespace: <%= Rails.env %>
    expiry_batch_size: 100
    expiry_method: :thread
    trim_batch_size: 100
    encrypt: true
    shards:
      - cache_primary
      - cache_secondary

Two knobs matter here in practice:

  • expiry_method: :thread runs the trimmer in a background thread inside every Puma worker. There is no separate expiry process. On a 4-worker Puma setup, four threads coordinate through the solid_cache_entries table using SQL row locks.
  • expiry_batch_size and trim_batch_size control how many rows are deleted per sweep pass. The defaults (100) are fine for cache tables under 1 GB. Raise them to 1000 if your cache is larger and expiration is falling behind writes.

Encryption adds a 20–30% write overhead and a similar read overhead. On cache workloads dominated by hits, this shows up as a 5–10% latency bump in the median. Turn it on if you are caching anything user-identifying and never turn it off casually — flipping encrypt after the fact leaves you with unreadable historical entries.

Migrating from Redis to Solid Cache

The migration path I use for existing Rails applications on Redis has four phases. Do not skip any of them.

Phase 1: Add Solid Cache alongside Redis

Install the gem, run migrations, but keep Redis as config.cache_store in production. Solid Cache is present but idle:

# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV["REDIS_URL"],
  expires_in: 1.day
}

Ship this to production and verify migrations ran cleanly.

Phase 2: Enable Solid Cache in a shadow-write pattern

Wrap Rails.cache in a class that writes to both Redis and Solid Cache but reads only from Redis. This lets you validate that Solid Cache is receiving writes at the expected rate without affecting user latency:

# lib/dual_write_cache.rb
class DualWriteCache
  def initialize(primary:, shadow:)
    @primary = primary
    @shadow  = shadow
  end

  def write(key, value, options = nil)
    result = @primary.write(key, value, options)
    Thread.new { @shadow.write(key, value, options) rescue nil }
    result
  end

  def read(key, options = nil)
    @primary.read(key, options)
  end

  def fetch(key, options = nil, &block)
    @primary.fetch(key, options) do
      value = block.call
      Thread.new { @shadow.write(key, value, options) rescue nil }
      value
    end
  end

  # Delegate everything else to primary
  def method_missing(method, *args, &block)
    @primary.public_send(method, *args, &block)
  end
end

Wire it up in production.rb:

redis_cache = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
solid_cache = ActiveSupport::Cache::SolidCacheStore.new
config.cache_store = DualWriteCache.new(primary: redis_cache, shadow: solid_cache)

Run this for a week. Watch the solid_cache_entries table row count grow. Confirm the daily write rate matches what your Redis cluster reports.

Phase 3: Flip the read path

Swap primary and shadow. Now Solid Cache serves reads, Redis is the fallback. On a cache miss in Solid Cache, the class reads Redis and backfills Solid Cache:

def read(key, options = nil)
  value = @primary.read(key, options)
  return value unless value.nil?

  redis_value = @shadow.read(key, options)
  @primary.write(key, redis_value, options) if redis_value
  redis_value
end

Watch your cache hit rate metric closely. It will dip during the first hour as Solid Cache fills up from Redis reads. If steady-state hit rate is more than 3 percentage points below your Redis baseline, your max_size is too small — raise it before continuing.

Phase 4: Remove Redis

After a week on Solid Cache reads with Redis as fallback and no incidents, replace the DualWriteCache with the plain Solid Cache store and terminate the Redis cluster:

config.cache_store = :solid_cache_store

Delete the ElastiCache instance. Cancel the bill. Update config/database.yml to remove the Redis URL. This is the moment I usually take a screenshot for the client’s cost dashboard.

Monitoring Cache Hit Rate in Production

The single production metric that tells you whether your cache is healthy is hit rate. Below 80% and your cache is probably too small or your TTLs are too aggressive. Above 99% and you might be caching things that never change and should be static.

ActiveSupport::Notifications publishes cache_read.active_support and cache_write.active_support events for every cache operation. Subscribe once in an initializer and push counters to your metrics backend:

# config/initializers/cache_metrics.rb
ActiveSupport::Notifications.subscribe("cache_read.active_support") do |event|
  hit = event.payload[:hit] ? "hit" : "miss"
  StatsD.increment("rails.cache.read", tags: ["result:#{hit}"])
  StatsD.timing("rails.cache.read_duration_ms", event.duration)
end

ActiveSupport::Notifications.subscribe("cache_write.active_support") do |event|
  StatsD.increment("rails.cache.write")
  StatsD.timing("rails.cache.write_duration_ms", event.duration)
end

Compute hit rate as hits / (hits + misses) in your dashboard, sliced by minute. Alert on it dropping below 75% for more than 15 minutes — that usually means either the cache is being evicted too aggressively or a deploy shipped a change that invalidated a hot key.

For Solid Cache specifically, also track the solid_cache_entries table row count and byte size:

# lib/tasks/solid_cache_metrics.rake
namespace :solid_cache do
  task metrics: :environment do
    stats = SolidCache::Entry.connection.execute(<<~SQL).first
      SELECT count(*) as rows, sum(byte_size) as bytes
      FROM solid_cache_entries
    SQL

    StatsD.gauge("solid_cache.entries.count", stats["rows"])
    StatsD.gauge("solid_cache.entries.bytes", stats["bytes"] || 0)
  end
end

Schedule that every minute via Solid Queue recurring jobs. If the byte count stays within 10% of max_size for extended periods, the trimmer is working correctly. If it exceeds max_size and stays there, the trimmer is falling behind and you need to raise trim_batch_size.

When Not to Use Solid Cache

Solid Cache replaces Redis-as-cache. It does not replace Redis-as-everything-else. Keep Redis if you use it for:

  • Sidekiq queues — although if you also use Sidekiq purely as a job queue, look at migrating to Solid Queue alongside this.
  • Rate limiting with atomic counters — Solid Cache’s increment works, but at hundreds of ops per second it becomes a database hot row.
  • Pub/Sub for Action Cable — Solid Cable exists in Rails 8 too, but that is a separate migration.
  • Cross-application shared cache — if two Rails apps share a cache, keep them on Redis. Solid Cache is scoped to one database.

I also do not recommend Solid Cache on a cache workload above roughly 5,000 writes per second on a shared database. Under that write rate the impact on your primary database is negligible; above it, you start seeing wait time on WAL flushes for the cache database that ripples into your application queries. Move the cache to a dedicated Postgres instance in that case, which is what the database: cache config is designed for.

The other case I keep Redis for is heavy use of Rails.cache.write(key, value, race_condition_ttl:). Solid Cache supports it, but the write-plus-read-plus-refresh pattern involves a database transaction and multiple round trips. On Redis the same operation is a single command. If most of your cache writes use this option, benchmark before migrating.

The Cost-and-Complexity Math

For most Rails applications I work with, the numbers land somewhere like this. Redis on ElastiCache with a cache.t3.small instance runs $18–25 per month plus data transfer. A production-sized ElastiCache cluster with a replica is $150–400 per month. The cache workload on Postgres adds a measurable but small amount to your primary database load — on the client I mentioned, cache reads were 8% of their total Postgres query count after migration but only 0.4% of query time because each was a single-row primary-key lookup.

The operational simplification is bigger than the cost savings. One fewer service to monitor, one fewer set of security patches, one fewer thing to fail during a deploy, one fewer thing to reason about when debugging production. On teams under five engineers, that reduction in surface area is worth more than the dollars saved.

Rails 8 Solid Cache is not right for every application. But for the standard Rails app running Sidekiq-in-Postgres and Redis-as-cache-only, deleting Redis is a two-week project that permanently reduces your infrastructure footprint. The tooling is now good enough to make that a boring decision instead of a risky one.

FAQ

Is Rails 8 Solid Cache faster than Redis?

No. A Redis GET typically returns in 0.2–0.5 ms; a Solid Cache read hits Postgres and returns in 0.5–2 ms. For the fragment caching, view caching, and memoized query results that make up 95% of Rails cache workloads, the difference is invisible in end-to-end response times. If your application depends on sub-millisecond cache latency for a specific operation, keep Redis for that operation and use Solid Cache for everything else.

Does Solid Cache require a separate database?

No, but I strongly recommend it in production. You can point Solid Cache at your primary application database and it will create the solid_cache_entries table there. On low-traffic apps this is fine. On any app doing more than a few hundred cache writes per second, put the cache table in its own database — the WAL activity from cache writes competes with your application transactions for I/O. The database: cache config in config/cache.yml handles this cleanly.

How do I clear the Solid Cache in production without downtime?

Use Rails.cache.clear from a Rails console — it truncates the solid_cache_entries table. This is safer than the equivalent Redis FLUSHALL because the operation is transactional and does not block other database operations. For a partial invalidation, use namespace scoping in Rails.cache.delete_matched (supported in Solid Cache) or bump a cache key version constant to force misses on affected keys.

Can I use Solid Cache with a read replica for scaling reads?

Not directly. Solid Cache does its own routing through Rails multi-database configuration, but read replicas of the cache database defeat the purpose — a cache miss on a replica followed by a write to the primary invites replication-lag bugs. If you need to scale cache reads, either shard the cache database across multiple writers (Solid Cache’s shards: option handles this) or accept that a single Postgres instance can serve tens of thousands of cache reads per second when the working set fits in RAM.

Deleting Redis from a Rails 8 app? TTB Software helps teams migrate cache infrastructure, right-size their databases, and simplify their production stack. Nineteen years of Rails, and I’ll happily help you delete a service instead of add one.

#rails-8-solid-cache #solid-cache-production #rails-solid-cache-vs-redis #solid-cache-migrate-from-redis #rails-8-cache-store-configuration #solid-cache-database-sizing

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software