rails_named_cache
Multiple named cache stores for Rails, through Rails.cache(:name).
Rails.cache.fetch("users") { User.all } # default store, unchanged
Rails.cache(:memory).fetch("countries") { ... } # in-process, ~3 µs per read
Rails.cache(:redis).fetch("session") { ... } # shared across servers
Why:
- Speed. A named
MemoryStoreread is ~3 µs — no socket, no pool checkout, no wire protocol (numbers). - Isolation. Per-domain stores, so page-fragment churn can no longer evict the pricing table.
Rails.cache(:pricing).cleartouches nothing else. - No new API.
Rails.cache(:name)returns the configured store itself — no wrapper, no proxy. Stores live inconfig/instead of global constants;Rails.cacheis untouched.
Contents: Install · Configuration · Usage · Expiration · L1/L2 · MemoryStore speed · Testing · Advanced · FAQ · Be careful · Compatibility
Installation
bundle add rails_named_cache
rails generate rails_named_cache:install # optional: a commented initializer to start from
Ruby >= 3.3, Rails >= 7.0. Runtime deps are activesupport, railties, concurrent-ruby.
Configuration
One setting. Values are any ActiveSupport::Cache::Store:
# config/initializers/rails_named_cache.rb
Rails.application.configure do
config.named_cache_stores = {
memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte),
pricing: ActiveSupport::Cache::MemoryStore.new(size: 8.megabytes),
redis: ActiveSupport::Cache::RedisCacheStore.new(url: ENV.fetch("REDIS_URL")),
disk: ActiveSupport::Cache::FileStore.new(Rails.root.join("tmp/cache")),
null: ActiveSupport::Cache::NullStore.new
}
end
Rails shorthand works too, resolved through ActiveSupport::Cache.lookup_store:
config.named_cache_stores = {
memory: :memory_store,
redis: [:redis_cache_store, { url: ENV.fetch("REDIS_URL") }],
disk: [:file_store, "tmp/cache"]
}
Works from config/application.rb or any environment file too. An app that never sets it behaves
exactly as before. Per-environment stores, engines, boot ordering, error semantics and the registry
API are in Advanced configuration.
Usage
Rails.cache.fetch("key") { value } # default store, unchanged
Rails.cache(:memory).fetch("countries") { Country.all.to_a }
Rails.cache(:pricing).write("product/1", price)
Rails.cache(:null).fetch("anything") { computed } # never caches
store = Rails.cache(:memory) # a plain ActiveSupport::Cache::Store — full API, nothing wrapped
Unknown and nil names raise instead of falling back to another store:
Rails.cache(:unknown)
# RailsNamedCache::UnknownStoreError: Unknown named cache store :unknown
#
# Available stores:
# :memory
# :pricing
Rails.cache(nil)
# RailsNamedCache::ConfigurationError: Cache store name must be a non-empty Symbol or String, got nil
UnknownStoreError exposes #name and #available.
Expiration
Per-store defaults, so each domain gets its own policy:
config.named_cache_stores = {
memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte, expires_in: 10.seconds),
pricing: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), expires_in: 1.hour }],
geocode: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), expires_in: 30.days }]
}
Per-call overrides are ordinary ActiveSupport:
Rails.cache(:memory).fetch("flags", expires_in: 1.second) { Flag.enabled.to_a }
Rails.cache(:pricing).write("product/#{id}", price, expires_in: 30.seconds)
Rails.cache(:pricing).fetch("rate", expires_in: 1.hour, race_condition_ttl: 10.seconds) { fetch_rate }
Rails.cache(:pricing).fetch("report", expires_at: Date.tomorrow.midnight) { build_report }
No expires_in on a store means entries live until overwritten, deleted, or evicted by the backend.
L1/L2: memory in front of Redis
Not a gem feature — two named stores and two nested fetch calls:
# app/lib/layered_cache.rb
module LayeredCache
def self.fetch(key, l1_expires_in: 5.seconds, **, &block)
Rails.cache(:memory).fetch(key, expires_in: l1_expires_in) do
Rails.cache(:redis).fetch(key, **, &block)
end
end
def self.delete(key)
Rails.cache(:redis).delete(key)
Rails.cache(:memory).delete(key) # this process only
end
end
LayeredCache.fetch("countries", expires_in: 1.day) { Country.order(:name).to_a }
# L1 hit (< 5s): ~3 µs, no Redis. L1 miss: one Redis read, refills L1. Both miss: runs the block.
The L1 TTL is your staleness window. An L1 entry cannot be invalidated from another process, so
delete clears Redis at once while other workers keep serving their copy until it expires. Keep it
at seconds.
Why MemoryStore is so fast
| Redis / Memcached | MemoryStore |
|
|---|---|---|
| Network round trip | yes | no |
| Connection pool checkout | yes | no |
| Wire protocol encode/decode | yes | no |
| Value handling | serialize to bytes | deep-copy in memory |
Reading a small hash 200,000 times, Ruby 3.4.5 on Apple silicon:
MemoryStore: 362,918 reads/sec — 2.8 µs/read
FileStore: 51,202 reads/sec — 19.5 µs/read
Redis adds a socket round trip on top of the same bookkeeping. What is left in MemoryStore is the
deep copy that keeps callers from mutating cached state (dup for strings, Marshal otherwise), so
keep values small and plain.
The cost: it is per-process and dies with the process. Use it for cheap-to-recompute, read-constantly data; use a shared store for anything that must agree across processes.
Testing
# disable caching for one domain, restore after
config.around(:each, :no_pricing_cache) do |example|
original = Rails.cache(:pricing)
RailsNamedCache.register(:pricing, ActiveSupport::Cache::NullStore.new)
example.run
RailsNamedCache.register(:pricing, original)
end
config.before { Rails.cache(:pricing).clear } # or clear one store between examples
Advanced configuration
Boot order
Stores are registered after config/initializers run and before eager loading, so eager-loaded
code can call Rails.cache(:name).
Not available inside
config/initializers— a lookup in an initializer body raisesUnknownStoreError. Useconfig.after_initialize,to_prepare, or application code.
Per-environment stores are plain Rails configuration:
# config/environments/test.rb
config.named_cache_stores = { pricing: ActiveSupport::Cache::NullStore.new }
Isolating stores that share a backend
Two stores on the same Redis, Memcached or directory share one keyspace — different objects, same keys:
Rails.cache(:short).write("user/1", "a")
Rails.cache(:long).read("user/1") # => "a" — not isolated
Rails.cache(:long).clear # also wipes :short
Give each a namespace. That isolates the keys and makes the store identifiable in logs and APM —
the cache_read.active_support payload carries namespace, otherwise both report only
store: "ActiveSupport::Cache::RedisCacheStore":
config.named_cache_stores = {
short: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), namespace: "short", expires_in: 1.minute }],
long: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), namespace: "long", expires_in: 1.day }]
}
Separate MemoryStore instances need no namespace.
Engines
Do not set config.named_cache_stores from an engine — railtie config is shared, so it replaces the
host app's stores. Register instead:
initializer "my_engine.caches", after: "rails_named_cache.register_stores" do
RailsNamedCache.register(:my_engine, ActiveSupport::Cache::MemoryStore.new)
end
Configuration errors fail at boot
Nothing is registered if any entry is invalid:
config.named_cache_stores = { pricing: nil }
# ConfigurationError: Named cache store :pricing is nil
config.named_cache_stores = { "pricing" => a, pricing: b }
# ConfigurationError: Duplicate named cache store :pricing
nil is rejected because ActiveSupport::Cache.lookup_store(nil) returns a fresh MemoryStore —
a typo would become a cache that quietly loses data. Names are non-empty symbols or strings, and
"memory" and :memory are the same store.
Registry API
RailsNamedCache.register(:memory, ActiveSupport::Cache::MemoryStore.new)
RailsNamedCache.fetch(:memory) # => the store (UnknownStoreError if missing)
RailsNamedCache.registered?(:redis) # => false
RailsNamedCache.names # => [:memory]
RailsNamedCache.registry.to_h # => { memory: #<MemoryStore> }
RailsNamedCache.reset! # empties the registry (tests)
Registration is by name, so stores registered by hand from an initializer survive boot unless
config.named_cache_stores names the same store.
FAQ
Why not global constants?
Configuration ends up outside config/, tests stub constants, and nothing lists which caches exist.
Forking servers (Puma workers, Unicorn, preload_app!)?
Same caveat as config.cache_store: stores are built at boot, so with preloading they exist before
the fork. Pass connection-based stores as configuration ([:redis_cache_store, { url: ... }])
rather than a live client — Rails connects lazily per worker, while a pre-connected socket shared
across forks corrupts.
Do named stores get the per-request local cache?
No. Rails inserts LocalCache::Middleware for the default Rails.cache only, so two identical
reads in one request hit the backend twice. Nothing leaks — the local cache is inert — and you can
opt in with Rails.cache(:redis).with_local_cache { ... }.
Overhead?
One Concurrent::Map read per lookup, lock-free. Stores are instantiated once at boot.
Outside Rails?
RailsNamedCache.register / .fetch work anywhere; Rails.cache(:name) and
config.named_cache_stores need an application. Requiring the gem always loads rails/railtie, so
load order never matters.
Built-in L1/L2?
No — five lines in app code, and a built-in version would have to
guess your staleness window. Rails.cache(:memory, fallback: :redis) stays possible later without
breaking the API.
Be careful
Named stores make it easy to add caches, which makes it easy to add these:
- Memory multiplies.
MemoryStoredefaults tosize: 32.megabyteswhen you omit it — five named stores is up to 160 MB per process, times your Puma workers, times your servers. Setsize:on everyMemoryStoreexplicitly and size it against the container limit, not the host. - A
MemoryStoreis one lock. AMonitorguards reads and writes, so all threads in the process serialize on it; the ~3 µs above is single-threaded. Pruning also runs inside that lock (bounded bymax_prune_time, default 2 s), so an oversized store can stall every thread. Prefer several small stores over one huge hot one. - TTL does not reclaim memory. Expired entries are dropped when read or on
cleanup; eviction happens on write once the store exceedssize, pruning back to 75%. A store full of expired entries nobody reads still occupies RAM. - No cross-process invalidation.
Rails.cache(:memory).delete(key)and.clearaffect the calling process only — a console or rake task cannot flush the running workers. Anything that needs a coordinated purge belongs in a shared backend. - Two stores on one backend share one keyspace, and
clearon either wipes both. See Isolating stores that share a backend. - No per-request local cache. Repeated reads of the same key in one request hit a named store's
backend every time. On a Redis-backed store, wrap the work in
with_local_cacheor put a memory layer in front. - Cache plain data, not objects. Storing ActiveRecord instances or anything class-bound in a long-lived in-process store outlives code reloading in development and keeps stale classes alive. Arrays, hashes, strings and numbers stay cheap to deep-copy too.
- Never take a store name from user input.
Rails.cache(params[:store])raises, and the error message lists every registered store name. - Do not create a store per model. Splitting one working set across many small caches lowers
the overall hit rate, and every configured store is built at boot whether anything reads it or
not. Add a store when a value genuinely needs a different backend, size or TTL — otherwise
Rails.cacheis still the right answer. - Pass connection-based stores as configuration, not as a live client object, or a preloading forking server will share one socket across workers.
Compatibility
| | | | --- | --- | | Ruby | 3.3, 3.4, 4.0 | | Rails | 7.0, 7.1, 7.2, 8.0, 8.1 |
Every combination runs in CI. Note the Ruby floor: an older Rails is only supported on a modern Ruby, so a Rails 7.0 app still on Ruby 3.1 cannot install this gem.
Rails 6.0 and 6.1 work but are neither supported nor tested — the runtime uses no API newer than Rails 6.0, so relaxing the constraint locally is enough.
Development
bundle exec rake # specs + rubocop
BUNDLE_GEMFILE=gemfiles/rails_7.0.gemfile bundle exec rspec # one Rails version
ls gemfiles/*.gemfile # rails_7.0 … rails_8.1
The suite boots a real (tiny) Rails app in a tmpdir, so boot wiring is covered end to end. Design rules live in AGENTS.md.
Contributing
Bug reports and pull requests welcome at
https://github.com/igorkasyanchuk/rails_named_cache. Keep bundle exec rake green and add a spec
for anything that changes behaviour.
License
MIT — see LICENSE.