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.
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: :threadruns 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 thesolid_cache_entriestable using SQL row locks.expiry_batch_sizeandtrim_batch_sizecontrol 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
incrementworks, 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.
Related Articles
Rails Thruster: Replace Nginx with Rails 8's Built-In HTTP/2 Proxy in Production
Rails Thruster replaces Nginx as your HTTP/2 proxy in Rails 8 production. Configuration guide: TLS via ACME, compress...
Rails LLM Observability: Tracing Prompts, Latency, and Token Usage in Production with Langfuse
Rails LLM observability guide: trace prompts, latency, and token costs in production using Langfuse and ActiveSupport...
Rails Transactional Outbox Pattern: Reliable Event Publishing Without Dual-Write Failures
Rails transactional outbox pattern: eliminate dual-write failures and lost webhooks by publishing events atomically i...