Ruby on Rails
By batching database writes and bypassing Action Cable's default worker pool, Solid Cable 4.1 allows relational databases to match Redis throughput under heavy load—and surpass it on SQLite—eliminating the need to maintain a separate Redis cluster for WebSockets.
AI-assisted summary of A deep dive into Solid Cable - Nick Pezza — original source. Check the original for full context.
Source published:
Key ideas
- Solid Cable eliminates the need for dedicated Redis clusters in Rails 8 by backing Action Cable with an Active Record polling architecture that works across SQLite, PostgreSQL, and MySQL. (1:49)
- Solid Cable 4.1 introduces write batching via SQL insert_all and memoized configuration, allowing SQLite to hit 1,048 messages per second at 1,000 concurrent users and outperform Redis. (17:06)
- An upstream Rails optimization bypassing Action Cable's worker pool thread queue for default stream handlers expanded broadcast handling capacity by approximately 35% across all adapters. (15:30)
- Nick Pezza introduced ChronoCable, an experimental gem leveraging database persistence to guarantee strictly ordered, gap-recoverable Action Cable delivery for sequential streams like LLM tokens. (22:16)
Detailed notes
## Background Published on YouTube by the Ruby on Rails channel on October 1, 2026, this 26-minute talk features Nick Pezza, the author and maintainer of Solid Cable. According to the video description, Pezza breaks down how the database-backed Action Cable adapter operates under the hood, benchmarks it against Redis and PostgreSQL across SQLite, PostgreSQL, and MySQL, and shares the optimizations in Solid Cable 4.1 alongside a preview of an experimental gem called ChronoCable.
## TL;DR Nick Pezza explains how he built Solid Cable—originally in just 112 lines of code—to replace Redis with a database-neutral, Active Record polling adapter that ultimately became the Rails 8 default. After initial Grafana k6 benchmarks revealed high-concurrency bottlenecks, Pezza profiled the system and introduced major optimizations in Solid Cable 4.1, including memoized configurations, pre-hashed channel keys, batched database writes, and an upstream Rails patch that bypasses the Action Cable worker pool for default stream handlers. These improvements drastically flattened tail latency and nearly doubled MySQL and PostgreSQL throughput at 1,000 concurrent users, while pushing SQLite past Redis to reach up to 1,071 messages per second. Finally, Pezza previews ChronoCable, an experimental extension that leverages database persistence and client-side sequence tracking to guarantee strict in-order message delivery and automatic gap recovery for drops or out-of-order WebSocket frames.
## Atmosphere & style The talk carries a pragmatic, humble, and deeply engineering-focused tone, blending origin-story humor with rigorous performance analysis. Pezza guides the audience from the relatable frustration of managing extra infrastructure to a candid examination of benchmark failures—complete with a lighthearted nod to Dr. Ian Malcolm—before delivering a satisfying arc of profiling and architectural redemption. Watching the presentation feels like peering over the shoulder of a meticulous systems engineer who favors low-tech simplicity and transparent, data-driven problem solving.
## Bulletin - [01:49](https://www.youtube.com/watch?v=9WEABvHEKws&t=109s) Solid Cable eliminates the need for dedicated Redis clusters in Rails 8 by backing Action Cable with an Active Record polling architecture that works across SQLite, PostgreSQL, and MySQL. - [17:06](https://www.youtube.com/watch?v=9WEABvHEKws&t=1026s) Solid Cable 4.1 introduces write batching via SQL insert_all and memoized configuration, allowing SQLite to hit 1,048 messages per second at 1,000 concurrent users and outperform Redis. - [15:30](https://www.youtube.com/watch?v=9WEABvHEKws&t=930s) An upstream Rails optimization bypassing Action Cable's worker pool thread queue for default stream handlers expanded broadcast handling capacity by approximately 35% across all adapters. - [22:16](https://www.youtube.com/watch?v=9WEABvHEKws&t=1336s) Nick Pezza introduced ChronoCable, an experimental gem leveraging database persistence to guarantee strictly ordered, gap-recoverable Action Cable delivery for sequential streams like LLM tokens.
## Key takeaways - [00:11](https://www.youtube.com/watch?v=9WEABvHEKws&t=11s) Before Rails 8, Action Cable forced trade-offs between single-process isolation (Async), secondary operational dependencies (Redis), or an 8 KB payload ceiling (PostgreSQL LISTEN/NOTIFY). - [01:49](https://www.youtube.com/watch?v=9WEABvHEKws&t=109s) Solid Cable replaces push-based pub/sub with low-tech relational database polling, allowing applications to remove Redis without engine-specific primitives. - [03:00](https://www.youtube.com/watch?v=9WEABvHEKws&t=180s) The adapter achieves high broadcast throughput by bypassing Active Record model instantiation, callbacks, and transactions in favor of direct database inserts. - [06:27](https://www.youtube.com/watch?v=9WEABvHEKws&t=387s) To prevent table bloat without dedicated cron jobs, Solid Cable uses probabilistic auto-trimming on writes, mathematically calibrated to prune roughly twice the incoming write volume. - [06:27](https://www.youtube.com/watch?v=9WEABvHEKws&t=387s) Channel names are hashed into 64-bit integers with SHA-256 to drastically shrink index size on disk and accelerate polling scans. - [07:40](https://www.youtube.com/watch?v=9WEABvHEKws&t=460s) Subscribers track isolated starting lines and cursors via a thread-safe Concurrent::Map, preventing race conditions where new listeners received historical broadcasts. - [09:21](https://www.youtube.com/watch?v=9WEABvHEKws&t=561s) In burst-reconnect benchmarks ('Thundering Herd'), Solid Cable achieved parity with native push architectures across all major database engines. - [11:38](https://www.youtube.com/watch?v=9WEABvHEKws&t=698s) Profiling revealed that per-broadcast YAML re-parsing and per-poll channel re-hashing caused severe garbage collection spikes and CPU contention under high concurrency. - [14:08](https://www.youtube.com/watch?v=9WEABvHEKws&t=848s) Solid Cable 4.1 implements write batching using a dedicated ingestion thread that coalesces outgoing broadcasts into bulk insert_all statements based on size or timeout thresholds. - [15:30](https://www.youtube.com/watch?v=9WEABvHEKws&t=930s) Bypassing Action Cable's connection worker pool queue when streams use default handlers eliminated unnecessary thread context switches, flattening latency curves across all adapters. - [17:06](https://www.youtube.com/watch?v=9WEABvHEKws&t=1026s) At 1,000 concurrent users, Solid Cable 4.1 enabled SQLite to reach 1,048 msg/sec, while PostgreSQL reached 1,000 msg/sec and MySQL doubled its throughput to 961 msg/sec. - [18:57](https://www.youtube.com/watch?v=9WEABvHEKws&t=1137s) Decreasing the polling interval to 25 ms significantly slashes latency under moderate traffic, but aggressive polling hits diminishing returns on network-bound databases under saturation. - [20:45](https://www.youtube.com/watch?v=9WEABvHEKws&t=1245s) Auto-trimming expired records incurs minimal throughput penalties even under heavy loads, confirming it is an operationally safe default. - [22:16](https://www.youtube.com/watch?v=9WEABvHEKws&t=1336s) Action Cable does not guarantee sequential delivery across worker threads, creating chaotic UI rendering for ordered data streams like LLM token output. - [24:25](https://www.youtube.com/watch?v=9WEABvHEKws&t=1465s) ChronoCable enforces ordering by assigning channel-scoped sequence numbers on the server and buffering out-of-order payloads in the browser until sequence gaps are resolved. - [25:52](https://www.youtube.com/watch?v=9WEABvHEKws&t=1552s) Upon network disconnect and reconnect, ChronoCable detects missed sequence IDs via subscription confirmation payloads and fetches missing historical messages from the database.
## Detailed walkthrough ### [00:11](https://www.youtube.com/watch?v=9WEABvHEKws&t=11s) The Landscape of Action Cable Adapters Before Rails 8
Prior to Rails 8, Action Cable relied on three primary adapters to solve the core challenge of real-time communication: Async, Redis, and PostgreSQL. When a Rails application broadcasts a message, the adapter is responsible for delivering that payload across application processes so that WebSockets can transmit it down to connected browsers. The defining distinction between these adapters comes down to where messages can travel. The Async adapter is by far the simplest, requiring zero external dependencies, but it only functions within a single Rails process. While that works well in local development, production Rails applications are distributed across multiple processes and machines. Nick Pezza compares Async to "talking inside of a single room. Everyone in the room can hear you, but that message can't leave the room."
To break out of that single room, Redis and PostgreSQL offer shared communication mediums where one process publishes a message, the data store fans it out, and any listening process catches it. However, both present distinct operational trade-offs:
- **Redis**: Deploying Redis means provisioning, monitoring, and maintaining an entirely separate data store alongside the primary relational database. - **PostgreSQL**: Works out of the box if the application already runs on Postgres, but its native `LISTEN`/`NOTIFY` pub/sub mechanism enforces a payload ceiling of roughly 8 kilobytes. This limit creates real problems when broadcasting large Turbo HTML morphs or fragments. - **SQLite and MySQL**: Running an auxiliary PostgreSQL instance purely to provide Action Cable pub/sub makes little operational sense, leaving Redis as the begrudging default choice for non-Postgres stacks.
---
[01:49](https://www.youtube.com/watch?v=9WEABvHEKws&t=109s) The Spark: Killing Redis with Active Record
At his day job, Nick Pezza found himself unexpectedly tasked with managing his company's entire infrastructure. Eager to simplify operations, he audited their tech stack where Redis was being used for only two things: caching and Action Cable. He quickly migrated application caching over to Solid Cache, leaving Action Cable as the sole remaining dependency anchoring Redis to their servers.
Around that time, David Heinemeier Hansson (DHH) filed an issue in Rails regarding the extraction of an Active Record-backed Action Cable adapter from 37signals' Campfire. Nick assumed he could simply wait for Campfire's launch, drop in the extracted adapter, and decommission Redis. That hope vanished when a follow-up comment revealed Campfire had chosen to stick with Redis for Action Cable after all. Undeterred, Nick asked himself: "How hard can it be to implement an adapter that only uses Active Record?"
```mermaid flowchart TD accTitle: Push vs Poll in Action Cable Adapters accDescr: Comparison between push-based adapters like Redis/Postgres and pull-based polling in Solid Cable.
subgraph PushModel["Push-based Adapters (Redis / Postgres)"] direction TB P_Pub["Rails Publisher"] -->|"Publish / NOTIFY"| P_Store["Redis / Postgres"] P_Store -->|"Persistent connection push"| P_Sub1["Rails Worker A"] P_Store -->|"Persistent connection push"| P_Sub2["Rails Worker B"] end
subgraph PullModel["Solid Cable (Database-backed Polling)"] direction TB SC_Pub["Rails Publisher"] -->|"INSERT message"| DB[("SQL Database")] DB -.->|"Poll loop (every 100ms)"| SC_L1["Thread: Listener A"] DB -.->|"Poll loop (every 100ms)"| SC_L2["Thread: Listener B"] end ```
Unlike Redis and PostgreSQL, which maintain persistent open connections to push notifications immediately to Rails workers, an Active Record adapter aiming for full database neutrality cannot depend on engine-specific primitives like Postgres `LISTEN`/`NOTIFY`. Following the philosophy of Solid Queue, Nick embraced low-tech simplicity: polling. The system runs an infinite loop querying the relational database for newly arrived records, sleeps for a handful of milliseconds, and checks again.
---
[03:00](https://www.youtube.com/watch?v=9WEABvHEKws&t=180s) Architecture and Core Methods: Adapter and Listener
At the foundation of Solid Cable is the `Messages` Active Record model, which stores a `channel` string and a `payload`, with an index placed directly on `channel` to facilitate polling lookups. The adapter architecture is split into two classes:
1. **`Adapter`**: Inherits from Action Cable's base adapter, managing high-level adapter state and defining the public contract (`broadcast`, `subscribe`, `unsubscribe`, and `listener`). 2. **`Listener`**: Inherits from Action Cable's `SubscriberMap`, managing channel subscriptions, maintaining the list of watched channels, and running the background polling thread.
```mermaid flowchart LR accTitle: Solid Cable Class Hierarchy and Responsibilities accDescr: Class relationships and responsibilities between Adapter, Listener, and Action Cable base classes.
subgraph BaseClasses["Action Cable Base"] AC_Base["ActionCable::SubscriptionAdapter::Base"] AC_SubMap["ActionCable::SubscriptionAdapter::SubscriberMap"] end
subgraph SolidCableClasses["Solid Cable Implementation"] SC_Adapter["SolidCable::Adapter"] SC_Listener["SolidCable::Listener"] end
AC_Base --> SC_Adapter AC_SubMap --> SC_Listener SC_Adapter -->|"delegates to"| SC_Listener ```
The `Adapter` methods work through direct, lightweight mechanisms: - **`broadcast(channel, payload)`**: Bypasses full model instantiation, validations, callbacks, and automatic transaction wrapping by performing a direct Active Record `insert`. This ensures broadcast writes are as cheap and fast as possible. - **`subscribe(channel, subscriber)`**: Calls `listener.add_subscriber(channel, subscriber)`. That method is implemented on the `SubscriberMap` superclass, which stores subscribers in an internal hash guarded by a mutex to prevent race conditions. If the incoming subscriber is the first for that channel, it invokes `add_channel`, which simply appends the channel to an internal watched list. In Redis or Postgres, this is the exact spot where a `SUBSCRIBE` or `LISTEN` command would be executed. - **`unsubscribe(channel, subscriber)`**: Delegates to `remove_subscriber` on the `SubscriberMap`. When the last subscriber departs, it calls `remove_channel` to take the channel off the watched list, mirroring where Redis calls `UNSUBSCRIBE` and Postgres issues `UNLISTEN`.
---
[05:23](https://www.youtube.com/watch?v=9WEABvHEKws&t=323s) The Polling Loop and Version One Simplicity
When the `Listener` object initializes, it spins up a background thread that executes the main `listen` method. The loop runs continuously until shutdown:
```mermaid flowchart TD accTitle: Listener Polling Loop accDescr: Flow diagram showing how the listener thread checks for messages, updates IDs, broadcasts, and sleeps.
Start(["Start Listener Loop"]) --> Query["Query DB for messages WHERE channel IN (watched) AND id > last_seen_id"] Query --> Check{"New Messages Found?"} Check -- Yes --> Fanout["Broadcast each message to connected WebSockets"] Fanout --> UpdateID["Update last_seen_id to highest processed ID"] UpdateID --> Sleep["Sleep poll_interval (Default: 100ms)"] Check -- No --> Sleep Sleep --> Start ```
On every iteration, the thread queries the messages table for any entries matching watched channels whose IDs are greater than the last processed ID. It immediately broadcasts each match down to the registered subscribers and advances `last_id`. To prevent replaying historical data on boot, `last_id` was initially lazily initialized to the newest message ID present in the database. After processing, the thread sleeps for a configurable duration—defaulting to 100 milliseconds—before cycling again.
Nick notes that Version 1 of Solid Cable consisted of only about 112 lines of code across the adapter and listener, alongside the `Message` model and minor configuration parsing. Deployed into production at his company, it immediately permitted the retirement of their Redis cluster without incident.
---
[06:27](https://www.youtube.com/watch?v=9WEABvHEKws&t=387s) The Road to Rails 8: Auto-Trimming and Channel Hashing
A few months after Solid Cable 1.0 shipped, Nick received an unexpected email from DHH proposing that Solid Cable become the default Action Cable adapter in Rails 8. However, making it a framework default required solving a major operational hurdle: table bloat. Because broadcast messages become obsolete once delivered, keeping them forever degrades query performance over time.
To resolve this without introducing background cron workers, Solid Cable adopted probabilistic auto-trimming tied directly to write traffic: - Every time a broadcast occurs, if `auto_trimming` is active, the system runs a probabilistic check. - Writes have a small chance of triggering a trim query that deletes a batch of records exceeding the configurable retention period. - The probability is mathematically calibrated so that cleanup capacity averages roughly twice the volume of incoming writes, exerting consistent downward pressure on table growth as traffic surges.
Additionally, engineers at 37signals suggested avoiding large text indexes on the `channel` column. Solid Cable was updated to run channels through SHA-256 and store the result as a fixed-size integer (`channel_hash`). Indexing and querying this 64-bit integer drastically reduced index size on disk and accelerated database index scans during high-frequency polling.
---
[07:40](https://www.youtube.com/watch?v=9WEABvHEKws&t=460s) Fixing Subscriber Race Conditions and Channel Starting Lines
Real-world adoption surfaced an edge-case bug in message targeting. Originally, Solid Cable maintained a single global `last_id` across the entire listener. If a client subscribed to a brand-new channel while the listener was already running, historical messages for that channel might still reside in the database with IDs greater than the listener's historical cursor, causing the new subscriber to erroneously receive stale broadcasts.
To eliminate this bug: - **Per-channel cursors**: Solid Cable tracks an independent `last_seen_id` for every active channel subscription. When a channel is registered via `add_channel`, it records the current maximum message ID, establishing that subscription's unique starting line. - **Concurrency handling**: Because channels are added and removed by request threads while the listener polling loop runs simultaneously on a background thread, the channel state was migrated into a `Concurrent::Map` using atomic operations to prevent race conditions. - **Eager startup**: Solid Cable retains a global cursor to scan forward through the table efficiently, but eagerly initializes it at startup to guarantee no messages slipping between listener boot and the initial query pass. Before dispatching any message, the listener verifies the payload ID against the specific channel's `last_seen_id`.
[08:38](https://www.youtube.com/watch?v=9WEABvHEKws&t=518s) Benchmarking Setup: Solid Cable 4.0 vs. Redis and Native Postgres
The global message ID keeps polling queries lean and guarantees that startup remains safe, while the per-channel message ID ensures subscribers strictly receive messages broadcast after their specific channel was registered. To determine how Solid Cable stacks up against traditional push-based solutions, a comprehensive benchmarking suite was executed against Solid Cable 4.0 and Rails `main` (pinned to a July 31st commit). All benchmarks ran on a single machine provisioned with two virtual CPUs and 8 GB of RAM. Hosting Redis, MySQL, and PostgreSQL locally on the same box eliminated external network variance. Solid Cable operated with a default polling interval of 100 milliseconds, and automatic message trimming was explicitly disabled to measure raw adapter performance without maintenance interference. Load generation and metric collection were handled via Grafana k6, with all benchmark applications and test scripts made available in the `bench` directory of the Solid Cable repository.
- **Hardware & Environment**: 2 vCPUs, 8 GB RAM; Redis, MySQL, and PostgreSQL colocated on the test box. - **Software Tested**: Solid Cable 4.0, Rails `main` (commit dated July 31). - **Solid Cable Tuning**: 100 ms polling interval; auto-trimming disabled to avoid background prune overhead. - **Tooling**: Grafana k6 for scenario simulation and metric capture.
---
[09:21](https://www.youtube.com/watch?v=9WEABvHEKws&t=561s) Constant Load and Thundering Herd Benchmarks
The first evaluation simulates constant load by sustaining a fixed population of active users over a 5-minute run. Each user connects, sends a ping, waits for the corresponding polling response message, repeats that cycle 20 times, and disconnects, at which point a replacement user immediately spawns into the pool. Throughput represents the number of full-trip round-trip messages per second. At 250 active users, performance across adapters is tightly grouped near 280 messages per second. When concurrency climbs toward saturation, Redis and the native PostgreSQL adapter scale up linearly. SQLite paired with Solid Cable holds up admirably, but Solid Cable running on PostgreSQL—and especially MySQL—plateaus around 500 users.
Latency exhibits the inverse relationship: round-trip times remain solid up to 500 users, but at 750 and 1,000 users, response times balloon across all adapters. Under heavy saturation, Solid Cable's latency degrades significantly faster (particularly against MySQL), while Redis and the native PostgreSQL adapter restrain their latency far longer.
The "Thundering Herd" test tests burst concurrency: a fixed block of users connects simultaneously, subscribes, transmits a single ping, verifies the receipt of the pong, and terminates the connection. This emulates a post-deployment restart when every connected client must reconnect concurrently. Completion speed is evaluated via round-trip latency and total elapsed duration.
| User Concurrency | Redis | Native Postgres | Solid Cable (Postgres) | Solid Cable (SQLite) | Solid Cable (MySQL) | |:--- |:--- |:--- |:--- |:--- |:--- | | **250 users** | ~2.0s | ~2.0s | ~2.0s | ~2.0s | ~2.0s | | **500 users** | ~3.0s | ~3.0s | ~3.0s | ~3.0s | ~3.0s | | **1,000 users** | 5.2s | ~5.0s | 5.7s | 5.2s | ~6.0s |
Even under an aggressive reconnect spike, Solid Cable completes the herd storm in essentially the same ballpark as native push architectures.
---
[11:38](https://www.youtube.com/watch?v=9WEABvHEKws&t=698s) The Lecture Benchmark: Fanout Limits and Hotspots
The "Lecture" scenario evaluates broadcast fan-out efficiency: a swarm of clients subscribes to a single shared channel while a producer broadcasts 7 messages per second continuously for 5 minutes. At 250 subscribers, Redis and native PostgreSQL report an average latency of approximately 70 milliseconds, whereas Solid Cable averages around 120 milliseconds. The 50 ms difference closely matches Solid Cable's 100 ms polling window. At 500 subscribers, Redis and PostgreSQL average 90 milliseconds, while Solid Cable floats near 145 milliseconds.
By 750 subscribers, the system hits extreme friction. Redis and native PostgreSQL retain a 95th-percentile (P95) latency below 200 milliseconds, whereas Solid Cable surges to 400–500 milliseconds. Pushing the delivery frequency to 10 messages per second at 750 subscribers caused *every* adapter—including Redis and native PostgreSQL—to fail the benchmark entirely.
Looking at these degradation curves and searching for a positive interpretation brought to mind "the immortal words of the great Dr. Ian Malcolm: We can do better." Detailed profiling uncovered several distinct performance sinks:
1. **YAML Re-parsing & Config Allocations**: On every broadcast, Solid Cable checked whether auto-trimming was active; on every polling pass, it inspected the polling interval and logging silence settings. Each check was reading the `cable.yml` file, re-parsing YAML structures, and allocating throwaway objects, causing garbage collection spikes. The fix memoized the configuration into a dedicated configuration object parsed once on boot. 2. **Channel Key Re-hashing**: Previously, the watcher stored the raw string channel name in its tracking map and re-hashed every channel key on every polling iteration. The optimization hashes the channel string once upon subscription and keys the map by hash, permitting the polling thread to inject the hashes directly into the database query without repeating work.
---
[14:08](https://www.youtube.com/watch?v=9WEABvHEKws&t=848s) Write Batching in Solid Cable
Under high broadcast rates, inserting every single broadcast individually generated intense write contention on the database engine. Because Solid Cable already batched *reads* via poll cycles, the write path was refactored to apply that same batching strategy. The new broadcaster batches outgoing messages based on two boundaries: a maximum batch size threshold or a maximum elapsed delay interval, whichever boundary is hit first.
```mermaid flowchart TD accTitle: Solid Cable Batched Broadcaster Flow accDescr: Incoming broadcasts enter a thread-safe queue where a worker thread drains them into batches and inserts them via insert_all.
subgraph Ingestion ["Ingestion"] A["broadcast(channel, payload)"] --> B["Wrap in Message Object"] B --> C["Push to Ingestion Queue"] end
subgraph BatchWorker ["Broadcaster Thread"] C --> D["Queue.pop (Blocks until first message)"] D --> E["Drain pending items from queue"] E --> F{"Batch size met OR timeout reached?"} F -- "Yes" --> G["SolidCable::Message.insert_all(...)"] F -- "No" --> E end ```
The broadcaster launches a dedicated background thread monitoring the ingestion queue. The thread blocks on `pop` until a message arrives, avoiding wasteful CPU spinning. Once awakened, it drains all accumulated queue items, accumulates messages up to the batch limit or delay deadline, and issues a single SQL `insert_all` command.
---
[15:30](https://www.youtube.com/watch?v=9WEABvHEKws&t=930s) Action Cable Worker Pool Bypass and Benchmarking 4.1
When the lecture test crumbled at 10 messages per second across all adapters, the common point of failure pointed to Rails core internals rather than database drivers. When Action Cable registers a stream via `stream_from`, it builds a stream handler. In the absence of a custom application callback block, Action Cable defaults to a standard handler that merely relays the raw broadcast buffer over the WebSocket connection.
Historically, this basic default handler was wrapped inside a `WorkerPoolStreamHandler`. Every broadcast had to hop onto the connection worker thread pool before hitting the socket. While this worker queue prevents custom Ruby callbacks from blocking Action Cable's event loop executor, it added a pointless thread context switch and queue traversal for the default case. The upstream patch circumvents the worker pool entirely when no user callback block is supplied, dispatching messages straight to the socket.
```mermaid flowchart LR accTitle: Stream Handler Routing Optimization accDescr: Action Cable avoids routing default broadcasts through the worker pool thread queue.
subgraph Legacy ["Pre-Optimization"] L1["Broadcast Received"] --> L2["Worker Pool Thread Queue"] L2 --> L3["WebSocket Frame Sent"] end
subgraph Optimized ["Rails Upstream Fix"] O1["Broadcast Received"] --> O2{"Has Custom Callback?"} O2 -- "Yes" --> O3["Worker Pool Queue"] O2 -- "No (Default)" --> O4["Direct WebSocket Send"] end ```
This optimization, merged into Rails core, cleared the broadcast congestion bottleneck. Solid Cable was re-evaluated under these updates with the batch broadcaster tuned to a batch size of 4 and a 1-millisecond delay. (The native PostgreSQL adapter was omitted from the subsequent charts, as its behavior tracked Redis near-identically, and Thundering Herd was omitted due to its brief, bursty nature showing negligible change.) At 250 users under constant load, throughput numbers revealed significant improvements.
[17:06](https://www.youtube.com/watch?v=9WEABvHEKws&t=1026s) Benchmark Gains Across Adapters and Action Cable Fixes
At the lower end of the spectrum with modest user counts, the recent optimizations leave performance basically unchanged because traffic levels simply are not high enough for the changes to show a noticeable difference. Once the test scales to 500 and 750 users, however, the performance gains become readily apparent. Both Postgres and MySQL close much of their historical performance gap with SQLite, and all three database adapters advance significantly closer to Redis. At the 1,000-user mark, the leap is even more dramatic: SQLite actually edges ahead of Redis, delivering 1,048 messages per second. PostgreSQL surges from 609 to 1,000 messages per second, while MySQL nearly doubles its throughput, jumping from 491 to 961 messages per second.
The latency improvements tell an even more compelling story. Prior to these architectural optimizations, Solid Cable's latency rapidly deteriorated as user counts climbed into higher tiers. With the new changes in place, the latency curve remains dramatically flatter between 500 and 1,000 concurrent users. MySQL’s P95 latency drops from 412 milliseconds down to 174 milliseconds at 500 users, and tumbles from 953 milliseconds to 228 milliseconds at 750 users. PostgreSQL follows an identical trajectory, falling from 668 milliseconds down to 231 milliseconds at 750 users. At 1,000 users, SQLite reduces its average latency from 390 milliseconds to 140 milliseconds, PostgreSQL drops from 477 milliseconds to 179 milliseconds, and MySQL plummets from well over a second to just 210 milliseconds. Tail latency emerges as the standout metric: SQLite posts a substantially better P95 than Redis at both 750 and 1,000 users, translating into superior throughput, while PostgreSQL and MySQL move from trailing far behind to essentially matching Redis at 1,000 users.
The "lecture" benchmark—simulating one publisher broadcasting to an expanding audience of listeners—demonstrates solid improvements across the board as subscriber counts rise. Instead of latency running away uncontrollably at the higher end, the response curve stays remarkably flat. Crucially, all adapters now successfully survive the stress test of 750 users at 10 messages per second, a benchmark run that previously crashed and failed. This represents an approximate 35% increase in total message handling capacity. Because these specific gains stem primarily from stream handler refactoring inside Action Cable itself rather than adapter-specific logic, the benefits propagate across every supported adapter.
- **Throughput at 1,000 users**: SQLite reached 1,048 msg/sec (surpassing Redis); PostgreSQL rose from 609 to 1,000 msg/sec; MySQL jumped from 491 to 961 msg/sec. - **P95 Latency at 750 users**: MySQL dropped from 953 ms to 228 ms; PostgreSQL dropped from 668 ms to 231 ms. - **Latency at 1,000 users**: MySQL dropped from >1,000 ms to 210 ms (P95); PostgreSQL dropped from 477 ms to 179 ms (P95); SQLite dropped from 390 ms to 140 ms (average). - **Lecture benchmark**: All adapters passed the previously failing 750-user / 10 msg/sec tier (~35% throughput increase).
---
[18:57](https://www.youtube.com/watch?v=9WEABvHEKws&t=1137s) Tuning Knobs: Polling Interval and Latency Trade-Offs
Solid Cable provides several configuration settings to fine-tune operational behavior. Evaluating these settings using the constant load test—which offers the cleanest composite view of throughput versus latency—reveals how database polling impacts delivery speeds. By default, Solid Cable uses a polling interval of 100 milliseconds. Slashing that interval down to 25 milliseconds increases database query frequency by a factor of four, but provides the system with opportunities to discover and process newly broadcast messages significantly earlier.
The benchmark data confirms an immediate latency reduction through 500 users and stretching close to 750 users. At 250 users, Solid Cable’s P95 latency drops from roughly 160 milliseconds down to approximately 90 milliseconds, and at 500 users it hovers around 115 milliseconds—not quite matching Redis’s instantaneous delivery, but closing the distance. However, once the test reaches saturation at 750 and 1,000 users, issuing reads four times as often creates diminishing returns for network-bound databases like PostgreSQL and MySQL. SQLite, by contrast, avoids network overhead and "keeps stretching its legs," widening its lead. Pushing SQLite down to an aggressive 10-millisecond polling interval at 250 users yields a 65-millisecond average latency and a 78-millisecond P95.
Throughput mirrors this pattern: decreasing the polling interval supplies a noticeable uplift across most Solid Cable adapters. SQLite benefits the most, accelerating up to 1,071 messages per second at 1,000 users. PostgreSQL and MySQL demonstrate modest throughput gains through 750 users, though MySQL surrenders a bit of throughput at the absolute top tier due to query saturation. The takeaway for production environments is clear: when traffic is light to moderate and delivery latency is critical, lowering the polling interval yields tangible gains. However, when a system operates near saturation, hyper-aggressive polling works against network databases. There is no single universal setting; tuning must balance specific latency requirements against the database engine's headroom.
- **25 ms Polling Interval Latency**: At 250 users, P95 dropped from ~160 ms to ~90 ms; at 500 users, P95 hit ~115 ms. - **SQLite at 10 ms Polling Interval (250 users)**: Achieved a 65 ms average latency and a 78 ms P95. - **SQLite Maximum Throughput**: Reached 1,071 msg/sec at 1,000 users under a 25 ms polling interval.
---
[20:45](https://www.youtube.com/watch?v=9WEABvHEKws&t=1245s) The Cost of Auto-Trimming and Solid Cable 4.1
Another core configuration knob is auto-trimming, which purges expired records from the messages table. Testing the overhead of background pruning involves configuring a trim batch size of 100 messages and a retention period of 30 seconds across a five-minute constant load test, keeping the default 100-millisecond polling interval so only one variable changes at a time. Under this setup, background worker threads actively prune expired records for the bulk of the test duration.
At 250 and 500 concurrent users, auto-trimming imposes zero noticeable penalty—it is effectively free. Even under a heavy load of 1,000 users, the throughput penalty remains negligible. SQLite throughput is virtually identical with or without trimming enabled. PostgreSQL sees a slight dip from 1,000 down to 956 messages per second, while MySQL moves from 961 to 942 messages per second. Latency absorbs a somewhat larger relative hit on PostgreSQL and MySQL, but the overall cost of keeping the database table lean remains modest. Unless an application is already running on the ragged edge of database hardware capacity, auto-trimming represents a sensible operational trade-off, which is why it is enabled by default.
Solid Cable delivers a simpler operational model by utilizing an existing relational database, eliminating the need to maintain a separate Redis cluster while remaining surprisingly close in performance under heavy load—or outperforming it in the case of SQLite. The only real architectural compromise appears at very low traffic levels: idle or lightly loaded Redis and native PostgreSQL adapters push messages instantly over long-lived sockets, beating Solid Cable’s polling pause. But for the vast majority of Rails applications, the primary database is more than capable of supporting Action Cable traffic directly. All performance improvements, batched writes, and latency fixes discussed are packaged and available in the Solid Cable 4.1 release.
- **Auto-trimming settings**: Tested with a 100-message batch size and a 30-second retention window. - **Throughput cost at 1,000 users**: PostgreSQL fell slightly from 1,000 to 956 msg/sec; MySQL shifted from 961 to 942 msg/sec; SQLite saw virtually no change. - **Solid Cable 4.1**: Released with all highlighted throughput optimizations and Action Cable stream-handling updates.
---
[22:16](https://www.youtube.com/watch?v=9WEABvHEKws&t=1336s) Solving Out-of-Order Messages: Introducing ChronoCable
Beyond performance tuning, Action Cable exhibits a structural limitation: it does not guarantee in-order message delivery. Action Cable hands incoming broadcast payloads off to a worker thread pool. Depending on operating system thread scheduling and execution timing, two messages broadcast in sequence `A` then `B` can easily be delivered over the WebSocket connection in reverse sequence `B` then `A`. For casual UI updates or notifications, transient misordering is harmless. But for modern reactive patterns—such as streaming tokens from a Large Language Model (LLM)—delivery order is vital. If text chunks arrive out of sequence, the resulting text renders incorrectly. A live demonstration firing a Turbo Stream that rapidly appends incrementing integer values exposes the failure mode vividly: numbers scramble on screen in "absolute chaos."
```mermaid flowchart TD accTitle: ChronoCable Message Sequencing Flow accDescr: Sequence assignment on broadcast and sequential verification on the client.
subgraph Server["Server Broadcast"] A["Broadcast Triggered"] --> B{"Ordered Stream?"} B -- "No" --> C["Normal Queue Storage"] B -- "Yes" --> D["Lock Channel Record"] D --> E["Increment Sequence ID"] E --> F["Attach ID to Message & Queue"] end
subgraph Client["Client Stream Processing"] G["Receive Message"] --> H{"Matches Expected ID?"} H -- "Yes" --> I["Consume Message & Increment Expected"] H -- "No (Ahead)" --> J["Buffer Message in Waiting Queue"] J --> K["Request History from Server"] end
F -.->|"WebSocket Delivery"| G ```
This ordering problem plays directly into Solid Cable’s strengths. Because Solid Cable persists every broadcast in relational database tables, sequence information can be saved alongside the payload. By assigning each message an auto-incrementing sequence number scoped to its specific channel, the system captures sufficient state to ensure clients consume payloads in the exact order they were broadcast.
To implement this model, Nick Pezza introduced ChronoCable, an experimental gem consisting of monkey patches for Action Cable and Solid Cable designed to enforce strict channel ordering and recover lost messages over flaky connections. ChronoCable introduces a `Channel` model that stores the channel hash, tracks the current integer sequence number, and marks whether ordered delivery is active. Developers can opt into ordered streaming via a new `deliver_in_order` parameter:
```ruby # Action Cable Channel stream_from "chat_room_1", deliver_in_order: true ```
When integrating with Hotwire and Turbo, the same flag can be passed down as a data attribute on a standard Turbo Stream tag:
```erb <%= turbo_stream_from "chat_room_1", data: { deliver_in_order: true } %> ```
Under the hood, ChronoCable intercepts every broadcast to check if the destination channel requires strict ordering. If enabled, it locks the corresponding channel row, increments the sequence counter, assigns that channel-scoped identifier to the message, and queues it for insertion into the database table, which now includes a `channel_id` sequence column. The architectural trade-off is throughput: row locking and individual sequence assignment cut into the write-batching gains introduced in Solid Cable 4.1.
---
[24:25](https://www.youtube.com/watch?v=9WEABvHEKws&t=1465s) ChronoCable Client Protocol and Gap Recovery
Because strict ordering cannot be solved on the backend alone, ChronoCable offloads sequencing logic to the browser, requiring the client-side JavaScript runtime to track local state for every active ordered stream. The connection handshake proceeds normally: the client transmits a standard subscription request containing the requested channel name. Upon confirmation, the server appends the stream's current sequence ID to the confirmation payload. If the server indicates that the latest sequence ID on the stream was 1, the browser initializes its internal state to expect sequence ID 2 as the next inbound message.
When messages arrive over the WebSocket, they carry their stream identifier and assigned sequence ID. If the sequence matches the client's expected integer counter, the browser immediately consumes the payload (e.g., rendering the Turbo Stream append) and advances its internal expected sequence counter by one.
If network jitter or thread scheduling causes a message to arrive ahead of sequence—such as message 5 arriving when the browser is waiting for message 3—the client pauses processing. It stores message 5 in an in-memory buffer (the waiting queue) without rendering it, and instantly dispatches a message back to the server requesting the stream’s history, asking for all missing messages starting from the last successfully rendered ID.
While awaiting the server's history response, any additional out-of-order broadcasts that arrive are placed into the waiting queue. Once the server returns the requested history payload, the client catches up through the following sequence:
1. It consumes the missing historical messages in strict order (e.g., sequence 3, then sequence 4), advancing its expected counter as it goes. 2. It processes the buffered messages in the waiting queue. 3. If an item in the waiting queue was already provided by the history response (such as sequence 4), the client discards the duplicate. 4. If a buffered message represents the next valid unseen message in sequence (such as sequence 5), it is consumed and the expected sequence advances. 5. Once the waiting queue is drained, normal real-time stream listening resumes seamlessly.
[25:52](https://www.youtube.com/watch?v=9WEABvHEKws&t=1552s) Recovering Missed Messages After a Connection Drop in ChronoCable
Nick walks through how ChronoCable handles network interruptions and ensures ordered, recoverable delivery when a client's internet connection drops. When the browser comes back online, reconnects, and resubscribes to the channel, the server's subscription confirmation includes the current state of the stream. In Nick's example, the confirmation tells the client that the stream is currently on message 7, whereas the browser was still expecting message 6. That discrepancy immediately alerts the client that messages were missed while the connection was down.
To recover, the browser requests the stream history from the server starting from the last message it successfully saw. Using that historical payload to catch back up, the client consumes message 6 and then message 7 strictly in order, advances its internal pointer for the next expected message, and seamlessly resumes normal real-time streaming.
```mermaid flowchart LR accTitle: ChronoCable Reconnection and Message Recovery Flow accDescr: Shows how a browser detects a gap between expected message 6 and current stream message 7 upon reconnecting, fetches stream history, and resumes normal streaming.
subgraph Reconnect["1. Reconnect & Detect Gap"] A["Internet drops & browser reconnects"] --> B["Subscription confirmation: stream at msg 7"] B --> C["Browser expects msg 6 (gap detected)"] end
subgraph CatchUp["2. Fetch History & Resume"] D["Request stream history from last seen msg"] --> E["Consume msg 6 then msg 7 in order"] E --> F["Advance expected pointer & resume streaming"] end
C --> D ```
[26:18](https://www.youtube.com/watch?v=9WEABvHEKws&t=1578s) Closing Thoughts and Repository Links
Wrapping up his preview of ChronoCable, Nick encourages anyone working with Action Cable channels where strict message ordering really matters to "give it a whirl and let me know." He concludes the presentation by sharing the repository links on screen and inviting the community to check out the projects, open issues, and share feedback.
## Notable quotes > [00:11](https://www.youtube.com/watch?v=9WEABvHEKws&t=11s) talking inside of a single room. Everyone in the room can hear you, but that message can't leave the room. — Nick Pezza
> [01:49](https://www.youtube.com/watch?v=9WEABvHEKws&t=109s) How hard can it be to implement an adapter that only uses Active Record? — Nick Pezza
> [11:38](https://www.youtube.com/watch?v=9WEABvHEKws&t=698s) the immortal words of the great Dr. Ian Malcolm: We can do better. — Nick Pezza
> [18:57](https://www.youtube.com/watch?v=9WEABvHEKws&t=1137s) keeps stretching its legs — Nick Pezza
> [18:57](https://www.youtube.com/watch?v=9WEABvHEKws&t=1137s) There is no single universal setting; tuning must balance specific latency requirements against the database engine's headroom. — Nick Pezza
> [20:45](https://www.youtube.com/watch?v=9WEABvHEKws&t=1245s) Unless an application is already running on the ragged edge of database hardware capacity, auto-trimming represents a sensible operational trade-off, which is why it is enabled by default. — Nick Pezza
> [22:16](https://www.youtube.com/watch?v=9WEABvHEKws&t=1336s) Action Cable exhibits a structural limitation: it does not guarantee in-order message delivery. — Nick Pezza
> [22:16](https://www.youtube.com/watch?v=9WEABvHEKws&t=1336s) absolute chaos — Nick Pezza
> [26:18](https://www.youtube.com/watch?v=9WEABvHEKws&t=1578s) give it a whirl and let me know. — Nick Pezza
## Mentioned Nick Pezza, David Heinemeier Hansson (DHH), Dr. Ian Malcolm, Ruby on Rails, Shopify, 37signals, Campfire, Solid Cable, Action Cable, ChronoCable, Solid Cache, Solid Queue, Active Record, Redis, PostgreSQL, SQLite, MySQL, Grafana k6, Hotwire, Turbo, WebSockets
## Video description Nick Pezza, who wrote and maintains Solid Cable, walks through how the database-backed Action Cable adapter works under the hood. He then benchmarks it against Redis and Postgres on SQLite, Postgres and MySQL.
Nick shares the optimizations in Solid Cable 4.1, including batched writes and a fix inside Action Cable itself, that bring it close to Redis and sometimes past it. He also previews ChronoCable, an experimental gem for ordered, recoverable message delivery.
***
Thank you Shopify for sponsoring the editing and post-production of these videos. Check out insights from the Engineering team at: https://shopify.engineering/
#RailsWorld #RubyOnRails #SolidCable #ActionCable #WebSockets #Performance
[Source](https://www.youtube.com/watch?v=9WEABvHEKws)