Solid Queue, Solid Cache, and Solid Cable: Rails 8's Redis-Free Defaults
Objective
37signals ran Resque against Redis for years at Basecamp and HEY, and by their
own account still needed seven separate gems — resque, resque-pool,
resque-scheduler, resque-pause, resque_supervised_fork,
sequential_jobs, scheduled_job — just to get job pausing, scheduling, and
supervised forking that a single well-designed backend should provide. Solid
Queue replaced all seven with one dependency backed by the app's own
database, and by late 2024 was running roughly 20 million jobs a day for HEY
alone. Solid Cache did something similar for caching: swapping Redis/Memcached
for a database table dropped Basecamp's cache-store cost by roughly 80% while
growing cache retention from days to months, and cut Basecamp's P95 request
duration from 375ms to 225ms — not because disk got faster than RAM, but
because a cache six times larger meant far fewer cold misses. This concept
is about what you actually get, and give up, when you let Rails 8's default
database-backed trio replace Redis rather than reaching for Sidekiq and
Redis-backed Action Cable out of habit.
Use Cases
- Standing up a new Rails 8 app and deciding whether to keep the Solid Queue / Solid Cache / Solid Cable defaults or swap in Redis-backed Sidekiq and Action Cable before you've even deployed once.
- Running on a single server (or a handful, via Kamal) where every accessory service — Redis, a separate cache daemon, a pubsub broker — is one more thing to provision, patch, and pay for.
- Debugging a stuck or duplicated job by querying
solid_queue_jobsandsolid_queue_failed_executionsdirectly with SQL, instead of reaching forredis-clior a Sidekiq Web UI. - Deciding whether a growing WebSocket feature (live notifications, a chat widget, dashboard updates) is still within Solid Cable's comfort zone, or whether it's crossed into "reach for Redis" territory.
- Choosing where to put a large, long-lived fragment cache when the honest bottleneck is cache size (hit rate), not per-read latency.
- Sizing the database connection pool once workers, dispatchers, cache expiry threads, and Cable pollers are all competing for connections alongside web requests.
Deep Dive
Why the "solid trio" exists
Rails 8 made three database-backed adapters — Solid Queue (jobs), Solid Cache (cache store), and Solid Cable (Action Cable pubsub) — the default for new apps, replacing what used to require Redis plus, in most real apps, a separate job-queue gem on top. DHH's Rails 8.0 announcement frames the "why" around deployment cost and a hardware observation, not around a benchmark win: "Nobody should have to pay orders of magnitude more for basic computing just to make deployment friendly and usable" — the animating idea behind Rails 8 pairing the Solid trio with Kamal 2 for a "no PaaS required" one- or few-server deployment story. The specific technical premise for all three adapters is stated directly: "Disks have gotten fast enough that we don't need RAM for as many tasks... [reaping] the simplification benefits of SSD and NVMe drives being orders of magnitude faster than good-old spinning rust." Fewer accessory services isn't a minor convenience for 37signals' deployment model — a Redis instance is one more piece to provision, secure, back up, and pay for on every server, and on a single-box Kamal deployment that overhead is proportionally much larger than it is on a large PaaS fleet that already runs a managed Redis anyway.
Solid Queue
Solid Queue is a database-backed Active Job backend. Its core mechanism,
confirmed directly from the gem's own README, is SELECT ... FOR UPDATE SKIP LOCKED (available in PostgreSQL 9.5+, MySQL 8+, MariaDB 10.6+) so that
multiple worker processes can poll the same solid_queue_ready_executions
table concurrently without blocking each other on row locks:
plaintext-- Solid Queue's actual polling query (no queue filter) SELECT job_id FROM solid_queue_ready_executions ORDER BY priority ASC, job_id ASC LIMIT ? FOR UPDATE SKIP LOCKED;
Three actor types do the work: workers pick ready jobs off that table and
run them (in a thread pool via threads:, or as fibers on a single reactor
thread via fibers: — mutually exclusive settings); dispatchers move
scheduled jobs whose time has come from solid_queue_scheduled_executions
into the ready table, and do concurrency-control maintenance; the
scheduler manages recurring tasks. A supervisor process forks and
monitors all of them (the default fork mode — async mode runs everything
in one process's threads instead, at the cost of isolation).
Recurring tasks are configured declaratively in config/recurring.yml:
plaintextproduction: clear_stale_sessions: command: "Session.clear_stale" schedule: every day at 9am send_daily_digest: class: SendDailyDigestJob schedule: "0 8 * * *"
Concurrency controls (limits_concurrency) throttle how many jobs with a
given key run at once — this is a Solid Queue feature, distinct from the
idempotency and fan-out/fan-in design patterns covered in
Background Jobs: Idempotency and Fan-Out:
plaintextclass DeliverAnnouncementToContactJob < ApplicationJob limits_concurrency to: 2, key: ->(contact) { contact.account }, duration: 5.minutes def perform(contact) # at most 2 of these run concurrently per account end end
Operationally versus Sidekiq: there's no separate Redis process to
provision or fail over — the queue lives in the same database you're already
running (recommended as a separate logical database, but still no new
service). The real trade-off is polling versus push: Solid Queue's own
documented defaults are a 0.1 second polling interval for workers and 1
second for dispatchers, which is the mechanism, not an approximation — a
worker picking up a freshly-enqueued job waits on that poll cadence, where a
Redis-backed queue with a blocking pop (BRPOP) can hand a job to a free
worker within milliseconds of enqueue. For most background work (sending an
email, processing a webhook, generating a report) that difference is
invisible; for a job whose whole point is sub-second reaction time, it's a
real, measurable latency floor that polling intervals alone don't erase.
Solid Cache
Solid Cache is ActiveSupport::Cache::Store backed by a dedicated
solid_cache_entries table instead of Redis or Memcached. Per the gem's own
README, it is explicitly a FIFO cache, not LRU: eviction doesn't track
access recency at all — it estimates current size/count by comparing max and
min primary key IDs, and once a write pushes the tracked write-counter past
50% of expiry_batch_size (default 100), a background thread deletes the
oldest expiry_batch_size rows, first by whether max_entries/max_size is
exceeded, otherwise by max_age (default 2 weeks). Deleting from one end of
the table while inserting at the other avoids fragmentation. The trade for
giving up LRU precision is operational simplicity — no per-read bookkeeping,
and eviction only runs when the cache is actively being written to, so an
idle cache costs nothing.
plaintext# config/cache.yml production: database: cache store_options: max_age: <%= 60.days.to_i %> max_size: <%= 256.gigabytes %>
The latency trade-off is real and 37signals is explicit about the number: moving Basecamp's cache to disk-backed Solid Cache made individual reads about 40% slower than their prior Redis setup. What made this an obviously good trade for them wasn't a magic disk-is-fast-enough trick — it was that the cache became roughly 6x larger at roughly 80% lower storage cost, which raised the hit rate enough that Basecamp's P95 request duration fell from 375ms to 225ms. A slower cache that's hit far more often beat a faster cache that's hit less often. This is a workload-dependent bet: an app that's lightly cached to begin with won't see the same payoff, and per Solid Cache's README, per-read latency still matters for that case — nothing here waives the cache-store-latency reasoning covered in Russian Doll Caching, which owns the strategy question of what to cache and how to key it, not the storage engine underneath.
Solid Cache also supports encryption at rest, real and directly
configurable — set encrypt: true in config/cache.yml (or
config.solid_cache.encrypt = true), on top of an app already configured for
Active Record Encryption. It uses a compression-disabled encryptor (the cache
already compresses) and a MessagePack serializer that stores roughly 40% more
data than the standard serializer, which matters because encrypted payloads
are stored in binary columns with real size limits.
Solid Cable
Solid Cable is Action Cable's pubsub layer backed by a solid_cable_messages
table instead of Redis, using polling (default 0.1.seconds) instead of
PUBLISH/SUBSCRIBE. The gem's own README states its performance target
plainly: "Despite polling, the performance of Solid Cable is comparable to
Redis in most situations." That claim is backed by benchmarks published in
the gem's own repo (k6 load test, SQLite, default 0.1s polling, 100 VUs):
average round-trip time ~136ms versus Redis's ~69ms on the same hardware — a
real, measurable gap, not "comparable" in the sense of identical, but small
enough not to matter for most UI-driven realtime features. That gap widens
under load: at 750 concurrent virtual users in the same benchmark, Solid
Cable's average RTT grew to ~548ms (Redis: ~163ms), and the gem's own docs
note that dropping the polling interval to 0.01s brings SQLite "comparable
to Redis" again — at the cost of ten times the polling query volume against
the database.
Messages are retained and autotrimmed based on message_retention (default 1
day) — a side benefit is that recent broadcast history is queryable for
debugging, something Redis pubsub never gave you since it holds nothing after
delivery. Autotrimming itself has a documented cost: the README notes it "can
negatively impact performance slightly depending on your workload because it
is potentially doing a delete on broadcast," which is why autotrim: false
plus a scheduled SolidCable::TrimJob is offered as an alternative for
high-broadcast-volume apps.
When to still choose Redis
None of the three adapters claim to be a universal replacement, and the honest decision framework tracks the same axis in all three cases: how much does sub-second-to-millisecond latency, at meaningful concurrency, actually matter for this specific workload —
- Solid Queue → Sidekiq/Redis: reach for Redis-backed Sidekiq when jobs need to start within single-digit milliseconds of being enqueued at sustained high volume (real-time trading, live bidding), or when you're already running Redis for other reasons and the operational cost of a second queue system exceeds the cost of one more Redis client.
- Solid Cache → Redis/Memcached: reach for an in-memory store when the workload is read-latency-sensitive at the microsecond level rather than hit-rate-sensitive, or when the cached data churns so fast that a large retention window buys nothing (a FIFO eviction policy on a cache that never has "cold" entries worth keeping around isn't earning its keep).
- Solid Cable → Redis (or AnyCable): Solid Cable's own benchmarks show the RTT gap widening as concurrent connections climb into the high hundreds; for genuinely high-throughput realtime (thousands of concurrent connections, many messages/second per connection, or sub-10ms delivery guarantees) the polling model is the wrong tool, and Redis pubsub (or a dedicated product like AnyCable) is the documented fallback.
The unifying signal: all three adapters trade a small, roughly-constant latency tax (a polling interval, a slower individual disk read) for operational simplicity and, in Solid Cache's case, a genuinely bigger resource (cache size). That's a good trade until the tax itself becomes the bottleneck — which for most CRUD-shaped Rails apps, it never does.
Trade-offs
- The transactional-integrity footgun: Solid Queue's README calls this
out directly. If the job database and the app database are the same
connection, enqueuing a job inside an
ActiveRecordtransaction ties the job's existence to that transaction's commit/rollback — powerful, but easy to depend on silently. Move Solid Queue to its own database later (the recommended, default setup) and that guarantee evaporates with no error:
Rails 8'splaintextApplicationRecord.transaction do order.update!(status: "paid") ChargeReceiptJob.perform_later(order.id) # enqueue rides the transaction... raise ActiveRecord::Rollback if suspicious? # ...until someone points Solid Queue at its own DB, at which point # ChargeReceiptJob may already have been enqueued and could run even # though the order update above got rolled back. endenqueue_after_transaction_commit(opt-in, off by default) fixes this properly instead of relying on incidental same-database behavior. - Solid Cache is FIFO, not LRU. A frequently-read key that happens to be old can be evicted before a key nobody has touched in weeks just because it was written more recently. For a workload with a genuine "hot set" distinct from write-recency, this is a real correctness-of-intuition gap, even though it's the deliberate, documented design (recency tracking would cost a write on every read).
- Concurrency controls have real overhead. Solid Queue's own docs warn
that
limits_concurrencyshould not be used as a general throttle — each controlled job needs a semaphore row created and updated, and controlled jobs lose the performance benefit of bulk enqueuing (perform_all_later) entirely, since they must be enqueued one at a time to respect the limit. For simple rate-limiting (as opposed to true mutual-exclusion), a dedicated low-concurrency queue with fewer worker threads is the documented, cheaper alternative. - All three adapters add connection pool pressure, on top of whatever
the web tier already needs — workers, dispatchers, cache expiry threads,
and Cable pollers each hold their own connections against whatever
database backs them. Solid Queue's own guidance is to size a thread
worker's
threads:at or below the queue database's pool size minus 2; getting this wrong doesn't fail loudly, it shows up as intermittentActiveRecord::ConnectionTimeoutErrorunder load — see Connection Pooling and PgBouncer for the pool-sizing math this interacts with. - "Fewer moving parts" doesn't mean "one database." The recommended
setup for all three adapters is a separate logical database each (queue,
cache, cable) — meaning a from-scratch Rails 8 app ships with four
configured databases (primary + three), each with its own migration path
and its own
database.ymlentry, and its own capacity/backup story. That's still fewer operational surfaces than Redis-plus-Sidekiq-plus-a- separate-pubsub-broker, but it's not zero new surface area either.