Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Rails Solid Cable: Redis-free WebSockets at Scale

ActionCable’s Redis dependency always annoyed me, and not for the reasons people usually cite. The latency argument against database pub/sub is mostly wrong at small scale — the real cost is that adding Redis for ActionCable means you now operate a second stateful system whose only job is moving a few thousand messages a second between app servers. Rails 8 (November 2024) made that optional: Solid Cable is the default production cable adapter, and it stores broadcast messages in a plain table and polls for new ones instead of maintaining a pub/sub connection. Polling sounds like a regression until you look at the numbers, which is what this post is about.

How it actually works

Every broadcast becomes a row in solid_cable_messages: a channel, a payload, and a monotonically increasing message_id. Subscribers don’t subscribe; they poll. Each connected socket’s server process periodically runs a query like:

1
2
3
SELECT payload FROM solid_cable_messages
WHERE channel = ? AND message_id > ?
ORDER BY message_id ASC LIMIT 50;

Because message_id is monotonic and only grows, the query is a range scan on a b-tree index, and each poll returns exactly what that subscriber hasn’t seen. That’s the whole trick: instead of an in-memory push, you get a durable, replayable stream per channel. Old messages are deleted by a trimmer — by default Solid Cable autotrims on broadcast, deleting rows older than message_retention (default one day) in batches, using FOR UPDATE SKIP LOCKED so concurrent trims don’t deadlock.

Configuration in Rails 8:

1
2
3
4
5
6
7
8
# config/cable.yml
production:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

Note the adapter still expects a separate database (here named cable) declared in config/database.yml — keeping message traffic off the primary connection pool is the single most important operational decision you’ll make with this stack. polling_interval defaults to 100 ms and message_retention to one day; both matter, and both are tunable to the traffic pattern.

Where it matches Redis, and where it doesn’t

The project’s own k6 benchmarks are the honest picture. Against a local instance at 250 concurrent virtual users, a 100 ms poll yields an average round-trip of ~150 ms on SQLite/MySQL/PostgreSQL versus ~69 ms for Redis — about 2x, which most UIs never notice. Crank polling to 10 ms and the average drops to ~84 ms, essentially indistinguishable from Redis. The gap that actually bites is concurrency, not latency: at 750 VUs, SQLite’s average RTT degrades to ~550 ms while Redis holds ~160 ms. Every socket that has a poll in flight is a query, and once you have thousands of sockets, you have thousands of queries per second hammering one table.

That scaling cliff is the honest dividing line. Solid Cable is the right call when:

  • you need to carry payloads larger than the ~8 KB limit of the built-in PostgreSQL NOTIFY adapter,
  • your deployment is one or a few app servers and Redis would exist only for ActionCable,
  • you want broadcasts to be durable and replayable for late-joining sockets.

It’s the wrong call when your fan-out is huge or bursty — thousands of simultaneous observers of a live event. At that point Redis, or better, AnyCable is the answer, and “Solid Cable couldn’t scale” becomes the wrong lesson to draw.

Production lessons from running it

  • Isolate the cable database and set autotrim on. The trimmer deleting old rows is the entire lifecycle management story; disabling it (autotrim: false) without a replacement is how the table grows until a range scan takes seconds. Let the retention window be your backpressure: 24 hours of messages at your peak rate is the table’s steady-state size.
  • Tune polling_interval per environment, not per app. We run dashboards at 100 ms polling for snappy activity feeds and drop a low-priority notification channel to 500 ms. The difference is linear in database load and barely visible to users.
  • Watch the connection pool, not just the table. Every polling process holds an ActiveRecord connection during its poll. A cluster of web processes with a shared cable database will happily exhaust the cable pool’s pool: size before the CPU gets busy. Set it deliberately.
  • Prefer MySQL/PostgreSQL over SQLite for multi-server. SQLite works and is a delight on a single box, but its writer lock serializes broadcasts and trims; the benchmarks above show it degrading earliest.
  • Give silence_polling a chance. The default silences AR logs for polls; if you’ve turned it off, the query log drowns in polling noise and your paging tooling gets useless.

The decision tree we ended up with: small to medium Rails app with a handful of servers, no Redis already in the stack, payloads over 8 KB, and modest fan-out — Solid Cable, no hesitation. A chat product with 5,000 live connections or a live-scores dashboard broadcasting to tens of thousands — Redis, or AnyCable if the pub/sub is the bottleneck. Solid Cable’s real achievement isn’t matching Redis; it’s making the no-Redis path so good that most Rails apps never need to ask the Redis question at all.

comments powered by Disqus