Inside Solid Cable: How a Polling, Database-Backed Action Cable Adapter Catches Up to Redis
Ruby on RailsNick Pezza wrote and maintains Solid Cable, the Active Record–based adapter for Action Cable. In this session he explains how it works internally, benchmarks it against the Redis and native Postgres adapters, and describes the optimizations in Solid Cable 4.1 that close most of the gap. His position is that for many Rails apps, the database they already run is capable enough to handle Action Cable. He also previews ChronoCable, an experimental gem for ordered, recoverable message delivery.
Why another Action Cable adapter
Before Rails 8, Action Cable shipped with three adapters: Redis, Postgres, and Async. Pezza says they all solve the same problem. When the application broadcasts a message, the adapter gets it to every Rails process that needs it, and those processes send it to connected browsers over their WebSocket connections. The adapters differ in how far a message can travel.
Async has no dependencies, but it only works inside a single Rails process. That is fine in development. Production apps usually run several processes, often on several machines. Pezza compares it to talking in a single room: everyone in the room hears you, but the message can't leave it. Redis and Postgres solve this by giving all processes a shared place to communicate. One process publishes, and the backing store distributes the message to everyone who needs it.
Each has its own costs. Choosing Redis means learning to operate another data store alongside the primary database. The Postgres adapter mostly just works if you already use Postgres, but its pub/sub mechanism limits payloads to about 8 kilobytes. Pezza says that limit can bite when Turbo broadcasts larger chunks of HTML. For apps on SQLite or MySQL, running Postgres only for Action Cable made little sense. As a result, he says, Redis tended to become the default.
His motivation was practical. At work they used Redis for both Action Cable and caching, and he unexpectedly became responsible for all of the company's infrastructure. To simplify things, he moved caching to Solid Cache, which left Action Cable as the only reason to keep Redis. Around then he saw an issue David had filed in Rails about extracting an Active Record–based adapter from Campfire. He planned to wait for it. Later a comment came through saying Campfire had ended up using Redis for Action Cable after all. At that point he decided to see how hard it would be to write an adapter that used only Active Record.
Polling instead of push
Redis and Postgres keep a persistent connection open to each Rails process, so they can push a new message immediately. A database-agnostic adapter built on Active Record can't depend on Redis pub/sub or Postgres LISTEN/NOTIFY. Pezza took the same low-tech approach as Solid Queue: polling. An infinite loop asks the database for new messages, sleeps for a few milliseconds, and checks again.
The core data structure is a messages model with a channel and a payload, plus an index on channel, because polling queries filter by it.
The adapter itself is two classes. The first inherits from Action Cable's base adapter class, which sets up shared state and defines the methods every adapter must implement. The second is the listener, which does the polling. It inherits from Action Cable's subscriber map, which tracks who is subscribed to each channel and delivers broadcasts to them.
The adapter class: broadcast, subscribe, unsubscribe
The adapter class implements four main methods: broadcast, subscribe, unsubscribe, and listener.
broadcast inserts a row into the messages table. It uses insert, which skips Active Record callbacks, validations, and the usual transaction wrapping. None of those are needed here, so the write stays as light as possible.
subscribe calls listener and then calls add_subscriber on it with the channel and subscriber. listener instantiates the listener object, but add_subscriber is not defined on the listener. It comes from the subscriber map superclass. When the subscriber map initializes, it sets up a hash of subscribers keyed by channel. Adding a subscriber appends it to that channel's list inside a mutex to avoid race conditions. If it is the channel's first subscriber, the map calls add_channel, which Solid Cable's listener implements by adding the channel name to a list of watched channels used during polling. Pezza points out that this is the spot where the Redis adapter would call subscribe and the Postgres adapter would issue LISTEN.
unsubscribe is the inverse. It calls remove_subscriber, which also lives on the subscriber map. The subscriber is removed from the channel's list, and if none remain, the listener's remove_channel drops the channel name from the watch list. This corresponds to unsubscribe in Redis and UNLISTEN in Postgres.
The listener loop
At the end of initialization, the listener starts a new thread and calls listen inside it. listen loops until the listener is shut down. On each pass it queries for messages on the watched channels with an ID greater than the last one it has seen and broadcasts each to its subscribers. After each broadcast it updates the last ID so the next poll continues from there. On first startup, the last ID is lazily initialized to the most recent message already in the table so old broadcasts aren't replayed. The loop then sleeps for a configured interval, 100 milliseconds by default.
Pezza says version 1 was only about 112 lines for the adapter and listener combined, plus the message model and some configuration parsing. That version ran at his company and let him remove Redis from their infrastructure without problems.
Becoming the Rails default: auto-trimming and hashed channels
A few months after version 1, Pezza got an email from DHH about making Solid Cable the default Rails adapter. One blocker was auto-trimming. Once a message has been broadcast, there's no need to keep it forever. Trimming deletes old messages so the table stays small and polling queries stay fast.
Solid Cable ties trimming to write activity rather than a fixed schedule. On broadcast, if auto-trimming is enabled, the trim job may run. With default settings, each write has a small chance of trimming up to a batch size of old messages. Pezza says the probability is tuned so that, over time, trimming has about twice the capacity of incoming writes. That keeps steady downward pressure on the table as traffic grows. A configurable retention period decides which messages are old enough to delete.
The other change 37signals suggested was storing a hashed form of the channel name on each message. SHA-256 turns the name into a fixed-size integer, which is stored next to the payload and indexed. Pezza says this gives a much smaller index and cheaper lookups. The listener now queries by channel hash instead of channel name.
A delivery bug: per-channel starting lines
One notable post-release bug involved deciding which messages a subscriber should receive. Solid Cable originally tracked a single last ID for the whole listener. If a new channel was subscribed while the listener was running, older messages for that channel could still be in the table, and the new subscriber should never see them.
The fix was to track a separate last-seen ID per subscribed channel. When a channel is added, the newest ID at that moment is recorded as that subscription's starting line. Because these IDs are added, updated, and removed from one thread while the listener polls on another, the per-channel state moved into a concurrent map with atomic updates. The global last ID remains so the listener can scan forward through the table efficiently. It is now initialized eagerly when the listener starts, so no messages slip through between initialization and the first poll. Before broadcasting, the listener also checks the channel's own last-seen ID. In Pezza's summary, the global ID keeps polling efficient and startup safe, and the per-channel ID ensures subscribers only get messages broadcast after their channel was added.
Benchmark setup
The first benchmarks used Solid Cable 4.0 and Rails main from a July 31 commit, on a machine with 2 CPUs and 8 GB of RAM. Redis, MySQL, and Postgres all ran on that machine to avoid network hops. Solid Cable used a 100 ms polling interval with auto-trimming disabled to isolate adapter performance. Load was generated and measured with Grafana k6. Pezza says the benchmark app and load scripts are in the bench directory of the Solid Cable repo.
Constant load
This test keeps a fixed number of users active. Each connects, sends a ping, waits for the pong, repeats 20 times, then disconnects and is immediately replaced. It runs for five minutes, simulating a steady stream of users connecting, exchanging messages, and leaving. Throughput is measured as messages per second making the full round trip.
At 250 users, all adapters were close, around 280 messages per second. As the system saturated, Redis and the native Postgres adapter kept scaling. Solid Cable on SQLite held up fairly well, while Solid Cable on Postgres, and especially on MySQL, started to level off around 500 users.
Pezza explains that latency (time from ping out to pong back) matters because clients spend longer waiting as it rises, and that eventually caps throughput. Up to about 500 users things were healthy. At 750 and 1,000 users latency climbed everywhere, but Solid Cable degraded more sharply, especially on MySQL. Redis and native Postgres kept latency down longer.
Thundering herd
This test connects a fixed number of users at once. Each subscribes, sends one ping, validates the pong, and disconnects. It models a sudden burst, such as every client reconnecting after a deploy. The measures are round-trip latency and total completion time.
At 250 users, completion times were all around 2 seconds. At 500 there was slight separation, but everything finished in about 3 seconds. At 1,000 users, Redis finished in 5.2 seconds, native Postgres in about 5, Solid Cable on Postgres in 5.7, Solid Cable on SQLite in 5.2, and Solid Cable on MySQL in about 6. Pezza's reading is that even under an aggressive reconnect storm, Solid Cable stays in the same general range as the push-based adapters.
Lecture: fan-out to many subscribers
This test subscribes many users to one channel and publishes seven messages per second for five minutes. It measures how efficiently a single broadcast fans out.
At 250 subscribers, Redis and native Postgres averaged about 70 ms, and Solid Cable about 120 ms. Pezza attributes most of that difference to the 100 ms polling interval. At 500 subscribers, Redis and native Postgres averaged about 90 ms and Solid Cable about 145 ms. At 750 the system came under real pressure. Redis and native Postgres stayed under 200 ms at P95, while Solid Cable rose to the 400–500 ms range. Pezza also noted that at 750 subscribers and 10 messages per second instead of seven, every adapter failed the benchmark. He returned to this later.
His overall assessment of 4.0 was that Solid Cable was competitive at lower load even with a 100 ms poll, but its weaknesses showed as load increased. After trying to find a positive spin on the high-end numbers, he says, Dr. Ian Malcolm's line "we can do better" kept coming to mind.
Finding the hotspots
Pezza instrumented various parts of Solid Cable and found several clear hotspots.
Configuration handling. Every broadcast checked whether auto-trimming was enabled. Every polling pass checked the polling interval and whether polling logs should be silenced. Each check re-read the cable config, re-parsed YAML, and allocated objects again, which Pezza says caused a surprising amount of redundant work and GC pressure. Parsing now happens once in a memoized configuration object that is reused everywhere.
Channel hashing. The watch list used raw channel names as keys, so every polling pass re-hashed every channel before building the query. Now each channel is hashed once when added, and the hash is stored as the key. The listener can put the hashes straight into the query.
Batched writes. Every broadcast was its own insert, which under heavy load meant many individual writes. Reads were already batched, so Pezza applied the same idea to writes. A new broadcaster collects messages into batches, flushing when either a maximum batch size or a maximum delay is reached. On startup it creates a queue and a dedicated thread to watch it. broadcast wraps the channel and payload in a small message object and pushes it onto the queue. The worker thread blocks on pop until the first message arrives, so it doesn't spin when idle. It then drains anything already waiting, in case it has fallen behind, and keeps collecting until the batch is full or the delay expires. The batch is written with insert_all in one SQL statement, which cuts write count and much of the overhead seen under load.
A fix inside Action Cable itself
Since Redis, Postgres, and Solid Cable all failed the lecture test at the same load, Pezza suspected Action Cable, and he says that turned out to be right. When stream_from is called, Action Cable builds a handler for the stream. Without a custom callback, it uses a default handler that essentially sends the broadcast down the WebSocket. Previously, even this default handler was wrapped in a worker-pool stream handler, so every message took an extra trip through the connection's worker thread pool.
That pool makes sense for custom callbacks, which can run arbitrary application code that shouldn't block the executor. For the default handler, Pezza says it just bounced every message through another thread pool with no real benefit. After his change, the worker pool is only used when there is a user-supplied callback. The default handler runs directly, removing a queue hop from every broadcast. He says this will ship in the upcoming Rails release.
Results after the changes
For the rerun, Pezza removed the native Postgres adapter from the charts to save space, saying its results were generally very close to Redis. He skipped the thundering herd test because it barely changed, since it has so little sustained traffic. Solid Cable's batch broadcaster used a batch size of four and a 1 ms delay.
Constant load throughput. At 250 users nothing changed much because traffic was too low for the optimizations to matter. At 500 and 750 users the gains appeared: Postgres and MySQL closed much of the gap to SQLite, and all three moved closer to Redis. At 1,000 users the improvement was larger. SQLite edged past Redis at about 1,048 messages per second, Postgres rose to about 1,000, and MySQL nearly doubled from 491 to 961.
Constant load latency. Pezza calls this the better story. The curve from 500 to 1,000 users is much flatter. MySQL's P95 fell from 412 ms to 174 ms at 500 users and from 953 ms to 228 ms at 750. Postgres showed the same pattern, falling from 668 ms to 231 ms at 750 users. At 1,000 users, SQLite's average fell from 398 ms to 140 ms, Postgres's from 477 ms to 179 ms, and MySQL's from over a second to 210 ms. He singles out tail latency as the biggest win. SQLite now has a much better P95 than Redis at 750 and 1,000 users, which he connects to its higher throughput. Postgres and MySQL went from far behind to roughly matching Redis at 1,000 users.
Lecture. Latency stayed much flatter as subscriber counts rose instead of running away at the high end. All adapters now passed the 750-subscriber, 10-messages-per-second test that had failed before. Pezza says this is roughly a 35% increase in messages handled, and that it comes mostly from the Action Cable stream handler change, so it benefits all adapters.
Tuning the polling interval
Pezza then looked at Solid Cable's configuration options using only the constant load test, which he says gives the most balanced view of throughput and latency.
Lowering the polling interval from 100 ms to 25 ms means polling four times as often in exchange for picking up messages sooner. Latency improved substantially through 500 users and into 750. At 250 users, Solid Cable's P95 fell from about 160 ms to about 90 ms, and at 500 users to about 100 ms. That is not quite Redis, but much closer. At 750 and 1,000 users, the extra reads gave diminishing returns for Postgres and MySQL, while SQLite pulled further ahead. Out of curiosity, he also ran SQLite at a 10 ms interval with 250 users and got a 65 ms average and 78 ms P95. That was closer still, but Redis kept the lead.
Throughput followed a similar pattern. The shorter interval gave a good boost to most Solid Cable configurations. SQLite gained the most, reaching about 1,071 messages per second at 1,000 users. Postgres and MySQL had modest gains through 750 users, and MySQL lost a little at the top end. His advice is that if traffic is fairly low and delivery latency matters, a lower interval may be worthwhile, but polling too aggressively can hurt as the system saturates, depending on the database. In his words, there isn't one perfect value, so tune it to what your infrastructure can handle and what your latency requirements are.
The cost of auto-trimming
To measure trimming, Pezza used a trim batch size of 100, a 30-second retention period, and the default 100 ms polling interval, so only one variable changed. With 30-second retention, trimming runs in the background for most of the five-minute benchmark.
At 250 and 500 users, trimming was essentially free, so those were left off the chart. At 1,000 users the throughput impact was small: SQLite was essentially unchanged, Postgres went from 1,000 to 956 messages per second, and MySQL from 961 to 942. Latency took a somewhat larger hit on Postgres and MySQL. Pezza concludes that continuous cleanup is fairly cheap, and unless you are already at the edge of capacity it is a reasonable trade-off, which is why it is on by default.
Where Solid Cable stands
Pezza summarizes Solid Cable as a simpler infrastructure option whose performance can now stay close to Redis under fairly heavy load, and in some cases, like SQLite, exceed it. The main trade-off is at low load. Redis and native Postgres usually deliver with lower latency because they push immediately instead of waiting for the next poll. If that matters, the polling interval can close part of the gap. For many Rails apps, he says, the existing database is more than capable of handling Action Cable too. All the improvements shown are in Solid Cable 4.1, which he says is out now.
ChronoCable: ordered, recoverable delivery
Pezza closes with an experimental project. Action Cable doesn't currently guarantee delivery order because messages go through a thread pool. Two broadcasts can be received in one order and delivered in another, depending on thread scheduling. Usually that doesn't matter, since nobody notices two notifications slightly out of order. For streaming an LLM response, though, out-of-order chunks can assemble the text incorrectly. He showed a demo that streams incrementing numbers as fast as possible, and they appear out of order.
He argues Solid Cable has an advantage here: since every message is stored in the database, ordering information can be stored with it. If each message gets a sequence number within its channel, there is enough information to consume messages in broadcast order. ChronoCable, the new gem, is a set of monkey patches for Action Cable and Solid Cable that lets a channel keep its messages in order and recover dropped messages. He stresses it is very much a work in progress and a lot may change.
On the server side, ChronoCable adds a channel model that stores the channel hash, tracks the channel's current sequence number, and records whether the channel uses ordered delivery. Streams opt in with a new deliver-in-order option on stream_from. When the stream is set up, ChronoCable creates the channel record and marks it as ordered. With Turbo, the option can be passed through data attributes on turbo_stream_from. On broadcast, ChronoCable checks whether the channel is ordered. If it is, it locks the channel record, increments the sequence, and attaches that channel-specific ID to the message before it is queued for storage. A new column on the messages table stores this sequence ID. Pezza acknowledges the cost: ordered channels need extra database work on every broadcast, which undercuts much of the batching benefit. He is still experimenting with ways to reduce it.
How the browser keeps messages in order
Most of the new work is on the front end, since the browser now keeps a little state per ordered stream. Subscription starts as usual. When the server confirms it, the server also sends the current sequence ID for each stream in the subscription. If the last message on "chat" was 1, the browser expects 2 next.
Each broadcast now includes the stream name and the message's sequence ID. If the ID is the expected one, the browser consumes it and advances the expected ID. If a message arrives ahead of the expected one, the browser sets it aside and asks the server for the stream's history starting from the last message it successfully consumed. Any further early messages that arrive while it waits are also set aside. In Pezza's walkthrough, the history response delivers messages 3 and 4, which are consumed in order. The browser then works through the set-aside messages: 4 was already recovered and is discarded, 5 is new and is consumed. With the waiting queue empty, the browser is caught up and returns to normal listening.
The same mechanism covers disconnects. If the WebSocket drops, for example because the internet connection is lost, the subscription confirmation on reconnect might say the stream is at message 7 while the browser still expects 6. The browser then knows it missed something, requests history from the last message it saw, consumes 6 and 7 in order, and resumes normal streaming.
Pezza invites anyone with channels where message order really matters to try ChronoCable and send feedback or issues through the project repositories.
Hey there, I'm Nick Pezza. I wrote and maintain Solid Cable, and today we're going to look at how it works and how it compares to the other Action Cable adapters in Rails.
Prior to Rails 8, Action Cable relied on one of three adapters: Redis, Postgres, and Async. They all solved the same basic problem. When your application broadcasts a message, that adapter is responsible for getting that message to the Rails processes that need to know about it. From there, those processes can send the message down to the connected browsers over their WebSocket connections. The important difference between these adapters is really about where those messages are able to travel.
The Async adapter is the simplest and has no dependencies, but it only works within a single Rails process, which works great in development. But production Rails apps usually run across multiple processes, often spread across multiple machines. Think of it like talking inside of a single room. Everyone in that room can hear you, but that message can't leave the room.
Redis and Postgres solve this by giving your Rails process a shared place to communicate through. One Rails process publishes a message, Redis or Postgres distributes it, and every process that needs that message can receive it. But they come with their own trade-offs.
If you choose Redis, deploying your Rails app now meant learning how to manage yet another data store alongside your primary database.
If your app already used Postgres, the Postgres adapter mostly just worked. The trade-off is that its pub/sub mechanism limits payloads to roughly 8 kilobytes. That's usually fine, but if you're using Turbo to broadcast larger chunks of HTML, you could run into that limit. And if your app uses SQLite or MySQL, standing up Postgres just for Action Cable didn't make much sense. So Redis tends to become the default choice.
At work, we were using Redis for both Action Cable and caching. I unexpectedly found myself responsible for managing all of our infrastructure. So I started looking for ways to simplify things and make my life a lot easier.
I moved our caching over to Solid Cache, which just left Action Cable as the last thing keeping Redis around. It was right around this time that I saw this issue that David had filed in Rails about extracting an Active Record-based adapter out of Campfire. So I figured I just had to wait for Campfire to land, drop the new adapter in, and finally remove Redis from our stack. But then a little while later, I saw this comment come through where Campfire had ended up using Redis for Action Cable after all. So I figured, how hard can it be to implement an adapter that only uses Active Record?
Redis and Postgres both keep a persistent connection open to each Rails process. So when a new message arrives, they can send that to the connected Rails process immediately.
To build an adapter on top of Active Record that's database agnostic, we can't depend on Redis's pub/sub or Postgres's LISTEN/NOTIFY mechanisms. So just like Solid Queue, we can lean on a little bit of low tech: polling. We can set up an infinite loop, ask the database if it has any new messages, and then sleep for a few milliseconds before checking again.
Before looking at the code for the adapter, at the core of Solid Cable is our messages model, which has a channel and a payload along with an index on channel, since that's what we'll be querying by as it polls for new messages.
The adapter is really made up of two classes. One inherits from Action Cable's base adapter class, which sets up some shared state and defines the methods every adapter needs to implement. The other is the listener, which in Solid Cable's case handles polling. It inherits from Action Cable's SubscriberMap, which keeps track of who's subscribed to each channel and handles delivering broadcasts out to them.
Our adapter class implements four main methods: broadcast, subscribe, unsubscribe, and listener. Let's start with broadcast. It just inserts a row into the messages table. Using insert lets us skip Active Record callbacks, validations, and the usual transaction wrapping, none of which we need here. So we can make the write as lightweight as possible.
Next up is subscribe. It calls listener and then calls add_subscriber on listener with the channel and the subscriber. So let's look at what that listener is actually doing. The listener method is responsible for instantiating the second half of the adapter, the listener object. But the listener itself doesn't define the add_subscriber method. That comes from its SubscriberMap superclass.
Inside SubscriberMap, initialization sets up a hash that tracks subscribers by channel. When we add a subscriber, we append it to the list for that channel. And all that happens inside of a mutex to avoid those pesky race conditions. If this is the first subscriber for the channel, we call the add_channel method, which is implemented by our listener class.
add_channel just adds the channel name to a list of watched channels that we'll use to gather messages to be sent out during polling. This is where Redis would call its subscribe method or Postgres would call its LISTEN command inside of their adapters.
That's subscribe. Let's look at unsubscribe. As you'd expect, unsubscribe does the inverse of subscribe. It calls remove_subscriber on the listener object. And just like add_subscriber, that method actually lives on SubscriberMap.
We remove that subscriber from the channel's list, and if there are no subscribers left, we call remove_channel, which is implemented by our listener class. It just removes the channel name from our list of watched channels. This is where Redis would call unsubscribe and Postgres would call UNLISTEN inside their adapters.
And with that, we've covered the main methods the adapter class defines. Pretty simple stuff. Now let's go look at the listener class initialization.
At the end of initialization, the listener starts a new thread and calls listen inside it. Let's go look at what that listen method is actually doing.
Listen starts a loop that keeps running until the listener gets shut down. On each pass through that loop, we query for any messages for the channel with an ID greater than the last one we've seen and then broadcast each of those messages out to their subscribers.
After we broadcast each message, we update last ID to that message's ID so the next poll knows where to continue from. And when the listener first starts up, we lazily initialize last ID to the most recent message already in the table so we don't replay old broadcasts. And finally, we sleep for a configured amount of time before starting the loop again. By default, it's 100 milliseconds.
And that's it. In version one, the adapter and the listener together were only about 112 lines of code, plus the message model and a little configuration parsing. That's the version I ended up deploying at work, and it let me remove Redis from our infrastructure without a hitch.
And then a few months after the release of version one, to my surprise, I got this email from DHH about adding Solid Cable to Rails as the default adapter. But one of the blockers to doing so was adding autotrimming. Once a message has already been broadcast, we don't need to keep it around forever. Autotrimming periodically deletes older messages so the table stays small and the polling queries stay fast.
Now when we broadcast out a message, we check if the config has autotrimming enabled, and if so, we run the trim job. Rather than trimming on a fixed schedule, Solid Cable ties cleanup to write activity. With the default settings, each write has a small chance of triggering a trim of up to the batch size of old messages. The probability is tuned so that over time, trimming has roughly twice the capacity of incoming writes. That gives the table consistent downward pressure as traffic increases, while a configurable retention period determines which messages are old enough to delete.
The other change the folks at 37signals suggested was adding a hashed version of the channel name to each message. We use SHA-256 to turn the name into a fixed-size integer, store that alongside the payload, and index that column. That gives us a much smaller index and cheaper lookups. So when the listener checks for new messages, it's querying by the channel hash rather than the channel name.
One of the more notable bugs that was found after release was around how we determine which messages should be broadcast. Originally, Solid Cable tracked a single last ID for the whole listener. The problem is that if you subscribe to a new channel while a listener is already running, there may be older messages for that channel still sitting in the table that this new subscriber should never receive.
So we started tracking a separate last-seen ID for each subscribed channel. When a channel is added, we record the newest ID at that point, which gives that subscription its own starting line. Because the per-channel last-seen IDs can be added, updated, and removed from one thread while the listener is polling on another, we move that channel state into a concurrent map and use atomic updates to avoid races.
We still keep the global last ID to efficiently scan forward through the table, and we eagerly initialize it now when the listener starts, so we don't miss any messages between initialization and the first poll. Then before broadcasting a message, we also check the last-seen ID for that specific channel. So the global ID keeps polling efficient and makes startup safe, while the per-channel ID makes sure subscribers only receive messages that were broadcast after that channel was added.
All right, let's look at some numbers and see how these adapters compare. All these benchmarks are run using Solid Cable version 4.0 and Rails main from a July 31st commit on a box with two CPUs and 8 gigs of RAM. Redis, MySQL, and Postgres were all running on that same machine to avoid any network hops, and Solid Cable was configured with a 100-millisecond polling interval. I also disabled autotrimming to isolate the adapter performance.
I use Grafana k6 to generate the load and capture the metrics, and all the benchmarking app and load testing scripts are available in the bench directory of the Solid Cable repo.
The first benchmark up is constant load. It keeps a fixed number of users active at all times. Each user connects, sends a ping, waits for the pong message, and repeats that 20 times. Once they finish, they disconnect, and a new user immediately takes their place. That cycle runs continuously for 5 minutes. So we're simulating a steady stream of users connecting, exchanging messages, and leaving.
And these are the throughputs for each adapter at different user counts. We're measuring how many messages per second make the full trip through the system and back. At 250 users, all the adapters are pretty close, around 280 messages per second.
Once we really start saturating the system, Redis and the native Postgres adapter keep scaling. Solid Cable on SQLite holds up pretty well, while Postgres and especially MySQL start to level off around the 500-user mark.
The other thing we care about in this benchmark is latency: how long it takes a ping to go out and the corresponding pong to come back. As latency increases, clients spend more time waiting, which eventually limits how much throughput the adapter can sustain.
Up through about 500 users, things are still pretty healthy. Around 750 and a thousand users, though, latency starts climbing across the board. Solid Cable degrades more aggressively under that load, especially on MySQL, while Redis and the native Postgres adapter hold their latency down for longer.
Next benchmark up is thundering herd. It connects a fixed number of users simultaneously, and each user subscribes, sends one ping, validates the pong, and then disconnects. It's testing how an adapter handles a sudden burst in connections, for example, after a deployment forces every client to reconnect at once.
The main thing we're looking at is how quickly each adapter works through the resulting backlog, which we measure through message round-trip latency and total completion time. At 250 users, the total completion times are all very close, right around 2 seconds. At 500 users, we start to see a little separation, but the adapter still clears the storm in roughly 3 seconds.
By a thousand users, though, the differences are a little more obvious. Redis finishes in 5.2 seconds and the native Postgres adapter in about five. Solid Cable on Postgres is still close at 5.7 seconds, while SQLite is even closer at 5.2, with MySQL bringing up the rear in around six. So even under a pretty aggressive reconnect storm, Solid Cable is still in the same general range as the push-based adapters.
And last up is the lecture benchmark. It subscribes a number of users to the same channel, then publishes seven messages per second for five minutes. It's designed to show how efficiently a single broadcast can fan out to a large number of subscribers at the same time.
These are the latency numbers. At 250 subscribers, Redis and the native Postgres adapter stay around 70 milliseconds on average, while Solid Cable is closer to 120. Since our polling interval is set to 100 milliseconds, that difference is mostly the cost of polling showing up. At 500 subscribers, everything is still holding together pretty well. Redis and the native Postgres adapter are around a 90-millisecond average, and Solid Cable is around 145.
At 750 subscribers, though, you start to see the system come under some real pressure. Redis and the native Postgres adapter are still under 200-millisecond P95, while Solid Cable climbs to the 400 to 500 range. I'll also note, at 10 messages per second instead of the seven we use at 750 subscribers, every adapter failed the benchmark. We'll come back to why that happened in a bit.
So in aggregate, Solid Cable stayed pretty competitive at the lower end of the load test, even with a 100-millisecond polling interval. But as we pushed the system harder and added more users, its weaknesses started to show. And after staring at those higher-end load numbers for a while and trying to find some positive way to put a spin on them, the immortal words of the great Dr. Ian Malcolm kept coming to mind: we can do better.
So I instrumented a bunch of different parts of Solid Cable to see where the time was actually going. And pretty quickly, a few obvious hotspots showed up that we could speed up. First up was configuration handling.
Every broadcast checks whether autotrimming is enabled, and every pass through the polling loop checks the polling interval and whether polling logs should be silenced. The problem was that each of those checks went back through reading the cable config, reparsing YAML and all the values, and allocating objects over and over again. Not super great under load.
That turned into a surprising amount of redundant work and GC pressure. So instead, we move all that parsing into a memoized configuration object. We read the cable config once, memoize all the values, and reuse that object everywhere.
Next up was how we tracked watched channels. When a channel's added, we add it to our watch list. We store the raw channel name as the key in our map. Then on every polling pass, we take all those channel names, hash them again, and use those hashes in the polling query. Now we hash the channel once when it's added and store that hash as the key in our channel map. Then when our listener polls, those hashes are ready to go straight into the query. No rehashing every channel on every pass through the loop.
Next up is batching writes. Right now, every broadcast results in its own insert into the messages table. That's simple, but under heavy load, it means a lot of individual writes hitting the database. We're already batching reads by polling for multiple messages at once. So we can apply the same idea on the write side: collect broadcasts together and insert them in batches to reduce write pressure.
The new broadcaster groups messages into batches based on either a maximum batch size or maximum delay, whichever happens first. When it starts up, it creates a queue for incoming broadcasts and spins
Up a dedicated thread to watch that queue. Each call to broadcast just wraps the channel and the payload in a small message object and pushes it onto that queue.
The thread that watches the queue starts by waiting for the first message to show up in the queue. Pop blocks the thread here until one arrives, so we're not spinning while there's nothing to do.
Once that first message comes in, we drain anything else that may already be waiting in the queue into our batch in case we've gotten behind.
Then we keep collecting messages until either the batch reaches its target size or the delay expires, whichever happens first. And then we stop collecting and flush the batch. Instead of doing one insert per broadcast, we use insert_all to write the entire batch in a single SQL statement. That cuts down on the number of writes in the database and reduces a lot of the overhead we were seeing under heavier load.
Lastly, like I said, while running the lecture benchmark at 750 subscribers and 10 messages per second instead of seven, Redis, Postgres and Solid Cable all crapped out. Since all the adapters failed around the same load, that led me to believe this was an issue inside Action Cable. And sure enough, it was.
When we call stream_from, Action Cable builds a handler for that stream. If you don't provide a custom callback, it uses the default handler whose job is basically just to take the broadcast and send it down the WebSocket.
Before this change, even that simple default handler was wrapped with a worker pool stream handler. So every incoming message took an extra trip through the connection worker thread pool before being sent to the client.
That extra pool is useful for custom callbacks because they can run arbitrary application code, and you don't want that work blocking the executor. But for the default handler, that extra hop just meant bouncing every message through another thread pool for no real benefit.
After this change, we only use the worker pool when there's actually a user-supplied callback. For the normal case, the default stream handler runs directly, removing that extra queue hop from every broadcast. This change will be included in the upcoming Rails release.
So let's rerun the benchmark and see where we landed after all these changes. I've removed the Postgres adapter from the upcoming charts to give us a little more room in the charts since its results were generally very close to Redis. I've also skipped the Thundering Herd benchmark here since it didn't really change after these updates, mainly because it had so little sustained traffic. For Solid Cable, I configured the batch broadcaster with a batch size of four and a 1 millisecond delay.
So the first benchmark up is constant load. These are throughput numbers. At 250 users, things are basically unchanged. The traffic just isn't high enough for these changes to make much difference.
At 500 and 750 users, though, we start seeing the gains. Postgres and MySQL close a lot of the gap with SQLite, and all three move much closer to Redis. At a thousand users, the improvement is much bigger. SQLite actually edges past Redis at ,48 messages per second. Postgres jumps from 69 to 1,000, and MySQL nearly doubles from 491 to 961.
It's an even better story with latency. Before these changes, Solid Cable's latency started falling apart as we pushed into the higher user counts. Now the curve is dramatically flatter from 500 to 1,000 users. MySQL's P95 drops from 412 milliseconds to 174 at 500 users and from 953 to 228 milliseconds at 750. Postgres shows the same pattern, dropping from 668 milliseconds to 231 at 750 users.
At 1,000 users, SQLite drops from a 398 millisecond average to 140. Postgres drops from 477 to 179, and MySQL goes from taking over a second to just 210 milliseconds.
And the tail latency is the real winner here. SQLite now has a dramatically better P95 than Redis at 750 and 1,000 users, which results in more throughput, while Postgres and MySQL have gone from being way behind to basically matching Redis at 1,000 users.
And finally, the lecture benchmark. Here we see solid improvements across the board as the subscriber count climbs. Latency stays much flatter than before instead of starting to run away at the higher end. And more importantly, all the adapters now make it through the 750 user, 10 messages per second test, which failed before. That works out to roughly a 35% uplift in messages being handled.
And these improvements mostly come from the stream handler changes inside Action Cable. So the benefit actually applies across adapters.
Solid Cable gives us a handful of knobs and dials to tune how it behaves. So let's see what those actually do to performance. For the next few benchmarks, I'm going to stick with just the constant load test since it gives us the most balanced view of throughput and latency.
Solid Cable's default polling interval is 100 milliseconds. So what happens if we drop that to 25? We'll be polling the database four times as often, but in exchange, we should be able to pick up new messages sooner. Let's see what that does to throughput and latency.
And sure enough, it gives us a pretty big latency improvement through 500 users and even into 750. At 250 users, Solid Cable drops from roughly 160 millisecond P95 to around 90. And at 500 users, we're down to around 100 milliseconds. Not quite down to Redis, but much closer.
Once we start saturating the system at 750,000 users, those extra reads start to show diminishing returns for Postgres and MySQL. SQLite, on the other hand, keeps stretching its legs, pulling further ahead.
Just to see how far we could push it, I also ran SQLite at a 10 millisecond polling interval with 250 users. That came in at 65 millisecond average and 78 millisecond P95. Closer still, but Redis still keeps the lead.
Throughput tells a similar story. Dropping the polling interval gives us a nice bump across most of the Solid Cable adapters. SQLite benefits the most, climbing to ,71 messages per second at 1,000 users. Postgres and MySQL show modest gains through 750 users, with MySQL giving a little bit back at the very top end.
So if your traffic is relatively low and delivery latency matters, it may be worth lowering your polling interval. But as your system gets more saturated, polling too aggressively can start to work against you depending on the database you're using. There isn't one perfect value, so tune it for what your infrastructure can handle and what your latency requirements are.
Now let's look at the cost of autotrimming. I'll be using a trim batch size of 100 messages and a retention period of 30 seconds. So for most of the five-minute benchmark, we'll be actively trimming old messages in the background. I'm also back to using the default 100 millisecond polling interval. So we're only changing one variable at a time.
At 250 and 500 users, autotrimming is basically free. So I've dropped those from the chart and zoomed in at the higher end. Even here, the throughput impact is tiny. At 1,000 users, SQLite is essentially unchanged. Postgres drops from 1,000 to 956 messages per second, and MySQL from 961 to 942.
Latency takes a somewhat bigger hit on Postgres and MySQL, but overall the cost of continuously cleaning up the table is pretty modest. So unless you're already operating right at the edge of capacity, autotrimming is probably a pretty reasonable trade-off, which is why it's enabled by default.
So that's Solid Cable. It offers a simpler infrastructure alternative while still delivering performance that can now stay surprisingly close to Redis even under fairly heavy load, and in some cases, like SQLite, exceed it. The biggest trade-off shows up at the low end when the system isn't under much load. Redis and the native Postgres adapter usually have a lower delivery latency because they can push messages immediately instead of waiting for the next poll.
If that low latency really matters, Solid Cable gives you room to tune the polling interval and close some of that gap. But for a lot of Rails apps, though, the database you already have is probably more than capable of handling Action Cable too.
All the improvements I've shown today are included in the 4.1 release of Solid Cable, which is out now. I did bring one more gift, though.
Action Cable currently doesn't guarantee message delivery order because messages are handed off to a thread pool. Two broadcasts can be received in one order but actually delivered in another depending on how those threads get scheduled. Most of the time that's not a big deal. If two notifications arrive slightly out of order, nobody notices.
But for something like streaming an LLM response, order matters a lot. If chunks of the response are delivered out of sequence, the text can be assembled incorrectly.
Here I have an example of that. I'm firing off a two-way stream that just appends incrementing numbers as fast as possible, and they appear out of order. Absolute chaos.
This is where Solid Cable has an advantage because every message is stored in the database. We can persist ordering information right alongside it. If we can give each message a sequence within its channel, then we have enough information to make sure that those messages are consumed in the same order that they were broadcast.
So I've been working on a new gem called ChronoCable, which is basically a collection of monkey patches for Action Cable and Solid Cable that allows a channel to keep its messages in order and can recover dropped messages. This is very much a work in progress, so lots may still change.
ChronoCable adds a new channel model that stores the channel hash, tracks the current sequence number for that channel, and records whether messages on that channel should be delivered in order.
You can opt into ordered delivery with a new deliver_in_order option on stream_from. When the stream is set up, ChronoCable creates the corresponding channel record and marks it for ordered delivery.
If you're using Turbo, you can pass deliver_in_order through the data attributes on turbo_stream_from to opt that stream into ordered delivery.
When we broadcast, ChronoCable first checks whether that channel uses ordered delivery. If it does, we lock the channel record, increment its sequence, and attach that channel-specific ID to the message before it gets queued for storage. ChronoCable also adds a new channel ID column on the messages table that stores this sequence ID.
The downside of this is it cuts into some of the benefit we were getting from batching because ordered channels now need extra database work on every broadcast. Still experimenting with ways to reduce that cost. But for now, let's look at how the front end works.
This is where a lot of the new work happens because the browser now has to keep a little bit of state for each ordered stream. The subscription starts like normal. The browser sends a subscribe message to the server with the channel it wants to listen to. When the subscription is confirmed, the server now sends back to us the current sequence ID for each stream in that subscription.
That tells the browser the last message on chat was one, so the next message it should expect is two.
When a message is broadcast, we now include the stream name along with that message sequence ID. We check that the message ID is the next one we're expecting. If it is, we consume the message and advance the expected ID to the next one in the sequence. But if a message arrives ahead of the one we're expecting, we don't consume it yet. Instead, we set it aside and ask the server for the stream's history starting from the last message ID that we successfully consumed.
While we're waiting for the missing message, another newer message might arrive. Since it is ahead as well, we set it aside with the others. When the history comes back, we consume the missing messages in order, three and then four, and then advance the expected ID as we go.
Then we work through the messages we set aside. We already recovered message four from the history response, so we discard it. Message five is new, so we consume it and advance the expected ID again. With the waiting queue empty, we're caught back up. So we can go right back to listening for new messages like normal.
If the website gets disconnected, say your internet drops, when the browser reconnects and resubscribes, the subscription confirmation will tell us that the stream is currently at message seven, but the browser was still expecting message six. So now we know we've missed something while the connection was down.
So we can ask the server for the stream history again, starting with the last message we saw, and we can use that to catch back up. We can see message six and then seven in order, advance what we're expecting next, and then go right back to normal streaming.
And that's ChronoCable. If you've got channels where message order really matters, give it a whirl and let me know.
And that's it. Here are the repo links if you want to check it out and give any feedback or issues. And thanks.
[music]
[music]
Article published · Updated
