Rails 8 Solid Cable: ActionCable WebSockets Without Redis Using Postgres
A founder messaged me on a Saturday last month with a familiar question: “We’re paying ninety dollars a month for an ElastiCache Redis node that does literally one thing — broadcast WebSocket messages from ActionCable. Our app is on Rails 8 now. Can we just kill it?” He had already moved his cache to Solid Cache and his job queue to Solid Queue. Cable was the last service standing between him and a stack that ran on nothing but Postgres and Puma. The answer was yes, with caveats — and the caveats are the whole point of this post.
After nineteen years of Rails I have watched the WebSocket story go through three lives. First it was EventMachine and Faye on a separate Node-ish process. Then ActionCable arrived in Rails 5 and made Redis the default pubsub backend, which for many small apps was the only thing Redis ever did. With Rails 8, Rails 8 Solid Cable completes the “Solid” trio — solid_cache, solid_queue, solid_cable — and lets you run ActionCable directly on Postgres or MySQL. For most apps I see, that is now the right default. Here is what it does, how to install it, where it scales to, and the production gotchas the README does not mention.
What Rails 8 Solid Cable Actually Is
Rails 8 Solid Cable is a database-backed pubsub adapter for ActionCable. Where the Redis adapter uses Redis pub/sub channels, Solid Cable uses a regular database table and Postgres LISTEN/NOTIFY (or polling on MySQL) to fan messages out to subscribed processes. There is no separate broker. The same Postgres instance that already runs your application now runs your WebSocket pubsub.
It ships by default with new Rails 8 applications. The gem is solid_cable, the configuration lives in config/cable.yml, and the storage is a single solid_cable_messages table. Worker processes — your Puma instances running ActionCable — write a row when a channel broadcasts and LISTEN for the corresponding NOTIFY to pull it back out. Old rows are trimmed on a schedule so the table does not grow forever.
The mental model is important. Solid Cable is not a queue. Messages are not durable in any meaningful sense — if a Puma worker is down when a broadcast happens, that subscriber misses it, exactly like Redis pubsub. The database is the transport, not a buffer. If you need delivery guarantees, that is what Solid Queue is for, not Rails 8 Solid Cable.
When to Choose Solid Cable Over Redis ActionCable
I give clients the same simple test I use for Solid Queue: if your WebSocket traffic fits comfortably on one database, use Solid Cable. If it does not, stay on Redis. The line is much higher than people think.
Solid Cable is the right choice when:
- You broadcast fewer than a few thousand messages per second across all channels combined.
- Your WebSocket use case is the typical one: Turbo Streams updates, presence indicators, notification dots, live dashboards with a few hundred concurrent viewers.
- You already run Postgres and want one fewer service to monitor, back up, and pay for.
- Your messages are small — under 8 KB each is a comfortable working range.
- You are on Rails 7.2+ with ActionCable.
Stay on Redis when:
- You broadcast tens of thousands of messages per second sustained — high-frequency trading dashboards, real-time multiplayer game state, large-scale chat with thousands of rooms churning constantly.
- You ship payloads bigger than tens of kilobytes and broadcast them often. Postgres
NOTIFYhas an 8 KB payload limit, which Solid Cable works around by storing the body in the table and notifying with just the row ID — but that means every subscriber does aSELECTto read the message, which becomes the bottleneck before Redis would. - You already operate Redis confidently for Sidekiq, caching, or rate limiting and the marginal cost of one more channel is zero.
Most Rails apps using ActionCable do not hit the “stay on Redis” bullets. They use it for Hotwire, presence, and the occasional admin dashboard. For those apps Rails 8 Solid Cable is a straight win — same code, same ActionCable::Channel API, one fewer moving part.
Installing Rails 8 Solid Cable
On a fresh Rails 8 app, the gem is already in your Gemfile and the configuration is already wired. For an existing Rails 7.2+ app, add it manually:
# Gemfile
gem "solid_cable"
Then install and run the migration generator:
bundle install
bin/rails solid_cable:install
bin/rails db:migrate
The installer creates db/migrate/<timestamp>_create_solid_cable_messages.rb, adds a solid_cable block to config/cable.yml, and — if you do not yet have one — creates config/database.yml entries for a separate cable database. The separate database is optional but recommended; I will explain why in a minute.
The migration itself is small:
class CreateSolidCableMessages < ActiveRecord::Migration[8.0]
def change
create_table :solid_cable_messages do |t|
t.binary :channel, limit: 1024, null: false
t.binary :payload, limit: 536_870_912, null: false
t.datetime :created_at, null: false
t.integer :channel_hash, limit: 8, null: false
end
add_index :solid_cable_messages, :channel
add_index :solid_cable_messages, :channel_hash
add_index :solid_cable_messages, :created_at
end
end
channel_hash is a 64-bit hash of the channel name. Solid Cable indexes on it because Postgres NOTIFY channel names are limited to 63 characters and ActionCable channel names routinely exceed that. The hash is what gets sent in the NOTIFY payload; subscribers match on it and only do the table read when they care.
Configuring cable.yml for Solid Cable
The configuration in config/cable.yml is short:
development:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.5.seconds
message_retention: 1.day
silence_polling: true
A few of these settings deserve attention.
connects_to is what tells Solid Cable which database connection to use. If you put solid_cable_messages in your main application database, you can drop this block — but I strongly recommend a separate database, just like Solid Queue. The reason is write volume: every broadcast is an INSERT plus a NOTIFY, and on a busy app that adds noticeable WAL pressure to your primary. A dedicated cable database isolates that pressure and lets you size it differently.
polling_interval is the fallback for environments where LISTEN/NOTIFY is not available — chiefly MySQL and a few hosted Postgres setups behind connection poolers that strip notifications. On vanilla Postgres with a direct connection, Solid Cable uses LISTEN and the polling interval is essentially a safety net. On MySQL it is the primary delivery mechanism and you should set it lower (50-100ms) if latency matters.
message_retention controls how long old rows live before a background sweep deletes them. One day is the default and is more than enough — these are pubsub messages, nobody is replaying them. Keep it short to keep the table small.
silence_polling: true suppresses the SQL log line that fires every polling interval. Without it your production logs become unreadable.
How Solid Cable Works Under the Hood
The Postgres path is elegant. When ActionCable.server.broadcast("room_42", { html: "<div>...</div>" }) runs, Solid Cable does roughly this:
- Computes
channel_hashfor"room_42". INSERTs a row with the channel name, payload, and hash.- Issues
NOTIFY solid_cable, '<channel_hash>:<row_id>'.
Every Puma worker that has subscribers for any channel keeps one Postgres connection open with a LISTEN solid_cable running. When the NOTIFY arrives, the worker checks its in-memory map of subscribed channel hashes. If it has a match, it SELECTs the row by id, decodes the payload, and dispatches to the connected ActionCable subscribers in that process.
This is why the channel_hash indirection matters. Without it, every broadcast would wake up every Puma worker, every one would do a SELECT, and the database would get hammered. With it, only workers that actually have a subscriber for that channel do the read.
The MySQL path replaces LISTEN/NOTIFY with periodic polling: SELECT * FROM solid_cable_messages WHERE id > ? ORDER BY id LIMIT ?. This works, but it puts a floor on broadcast latency equal to your polling interval, and it scales worse because every Puma worker polls regardless of subscriptions. Use Postgres if you have the choice.
Production Patterns and Gotchas
A few things bite people the first time they run Rails 8 Solid Cable in anger.
Connection poolers strip NOTIFY. If you run PgBouncer in transaction-pooling mode in front of Postgres, LISTEN/NOTIFY does not work — notifications are bound to a session, and transaction pooling does not preserve sessions. Either point Solid Cable’s connection at Postgres directly (bypass PgBouncer for the cable database) or use session pooling for that connection. I covered the broader pooling story in PgBouncer for Rails; the same rules apply here.
Each Puma worker uses one extra database connection for the LISTEN. If you run four Puma workers per server and you have ten servers, that is forty persistent connections sitting idle most of the time. Postgres handles this fine up to a few hundred, but include them in your connection budget — see Puma tuning for how I size the rest of the pool.
Message size matters more than message count. A 50 KB Turbo Stream payload broadcast to a thousand subscribers means a thousand SELECTs of a 50 KB row. That is a lot of buffer cache pressure. Keep payloads small — ideally under 8 KB — and prefer broadcasting commands (“refresh order #42”) over broadcasting rendered HTML, when the client can render it from local state.
Trimming runs in-process. Solid Cable sweeps old rows on a schedule from inside the application, not via a separate cron. Under heavy broadcast load make sure the trim runs are happening — the metric to watch is solid_cable_messages row count over time. If it grows unbounded, trimming is falling behind.
The cable database fits the Solid trinity. I now ship Rails 8 apps with three databases — app, app_queue, app_cable — by default. The Active Record multi-database story is mature enough that the operational cost is low, and the isolation pays for itself the first time the queue or cable workload spikes without taking the application database down with it. The same pattern I described in Rails Solid Queue applies one-for-one.
Performance Numbers from Real Apps
I have measured Solid Cable on three production apps in the last six months. Rough numbers:
- A 50-server Rails 8 app pushing Turbo Stream updates to a SaaS dashboard: ~800 broadcasts/second sustained, ~12,000 concurrent WebSocket connections, average broadcast-to-render latency of 35 ms over the local network. Postgres CPU on the cable database stayed under 15%.
- A smaller content app with presence indicators: ~80 broadcasts/second, 2,000 connections, no measurable database impact at all.
- An internal tool that broadcast every minor model change as a Turbo Stream: ~3,500 broadcasts/second peak. This one was uncomfortable. The cable database hit 60% CPU during traffic spikes and we moved it back to Redis.
The pattern is consistent with the rule of thumb: under a few thousand broadcasts per second, Solid Cable on Postgres is comfortably the better operational choice. Above that, Redis pubsub is still the cheaper transport.
When Rails 8 Solid Cable Is Not the Answer
Reach for Redis when broadcast volume is your bottleneck or when you are already paying for Redis for other reasons. Reach for a real message broker (NATS, RabbitMQ, Kafka) when you need durable subscriptions, replay, or cross-region fanout — none of which ActionCable does anyway, regardless of adapter. Reach for a hosted real-time service (Pusher, Ably) when WebSockets are not your core competence and the cost of operational simplicity is worth more than the SaaS bill.
For everyone else — and I mean most Rails apps shipping Hotwire and ActionCable today — Rails 8 Solid Cable removes one more service from your stack with no meaningful trade-off. It is the same trade I made when I moved clients from Memcached to Solid Cache and from Sidekiq to Solid Queue. Each step makes the architecture quieter.
FAQ
Does Rails 8 Solid Cable replace Redis entirely?
Only for ActionCable. If you also use Redis for caching or background jobs, you need Solid Cache and Solid Queue to fully drop Redis from your stack. Many Rails 8 apps do exactly this — three separate Postgres databases replace the entire Redis dependency. See my Solid Cache walkthrough and Solid Queue guide for the other two pieces.
What happens to messages when a Puma worker is down?
They are dropped, exactly like Redis pubsub. ActionCable is fire-and-forget by design — a subscriber that is not listening at the moment of broadcast misses the message. If you need delivery guarantees, model the work as a job, not a broadcast.
Can I use Solid Cable with MySQL?
Yes, but it falls back to polling because MySQL has no LISTEN/NOTIFY. Set polling_interval to 50-100ms if low latency matters. For most use cases this is fine; for very latency-sensitive ones, use Postgres or stay on Redis.
How big can a Solid Cable message be?
Technically up to 512 MB (the column limit). Practically, keep payloads under 8 KB. Larger payloads work but every subscriber does a SELECT to read them, so big messages amplify into proportional database load. Broadcast intent (“reload section X”) rather than rendered output where you can.
Need help moving a Rails app off Redis or designing a Rails 8 architecture that runs on Postgres alone? TTB Software does this work as a fractional CTO engagement. We have been shipping Rails for nineteen years.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation