Inside Solid Cable: How a Polling, Database-Backed Action Cable Adapter Catches Up to Redis

Open on YouTube ↗
Overview

Nick 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.

22 min read
0:22

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.

9:23

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.

13:14

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.

15:35

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.

16:48

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.

19:09

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.

20:57

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.

22:19

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.

24:38

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.