TurboCable
Custom WebSocket-based Turbo Streams implementation for Rails. Provides significant memory savings (79-85% reduction) for single-server deployments.
⚠️ Important Limitations
TurboCable is designed for specific use cases. Read carefully before adopting:
✅ When to Use TurboCable
- Single-server applications - All users connect to one Rails instance
- Development environments - Great for local dev with live reloading
- Single-tenant deployments - Each customer/event runs independently
- Resource-constrained environments - Memory savings matter (VPS, embedded)
- Simple real-time needs - Basic live updates within one process
❌ When NOT to Use TurboCable
- Horizontally scaled apps - Multiple servers/dynos serving same application (Heroku, AWS ECS, Kubernetes with replicas)
- Load-balanced production - Multiple Rails instances behind a load balancer
- Cross-server broadcasts - Need to broadcast to users on different machines
- High-availability setups - Require Redis or Solid Cable backed pub/sub across instances
- Bidirectional WebSocket communication - Client→Server data flow over WebSockets (chat apps, collaborative editing, real-time drawing)
- Action Cable channels - Custom channels with server-side actions and the channels DSL
If you need cross-server broadcasts or bidirectional WebSocket communication, stick with Action Cable + Redis/Solid Cable. TurboCable only broadcasts within a single Rails process and only supports server→client Turbo Streams.
Why TurboCable?
For applications that fit the constraints above, Action Cable's memory overhead may be unnecessary. TurboCable provides the same Turbo Streams functionality using a lightweight WebSocket implementation built on Rack hijack and RFC 6455, with zero external dependencies beyond Ruby's standard library.
Memory Savings (single server):
- Action Cable: ~169MB per process
- TurboCable: ~25-35MB per process
- Savings: 134-144MB (79-85% reduction)
Features
For applications within the constraints above:
-
Turbo Streams API compatibility - Same
turbo_stream_fromandbroadcast_*methods - Zero dependencies - Only Ruby stdlib (no Redis, no Solid Cable, no external services)
- Hybrid async/sync - Uses Active Job when available, otherwise synchronous (transparent)
-
Simple installation -
rails generate turbo_cable:install - All Turbo Stream actions - replace, update, append, prepend, remove
- Auto-reconnection - Handles connection drops gracefully
- Thread-safe - Concurrent connections and broadcasts
- RFC 6455 compliant - Standard WebSocket protocol
Installation
Add this line to your application's Gemfile:
gem "turbo_cable"Install the gem:
bundle installRun the installer:
rails generate turbo_cable:installThis will:
- Copy the Stimulus controller to
app/javascript/controllers/turbo_streams_controller.js - Add
data-controller="turbo-streams"to your<body>tag
Restart your Rails server and you're done!
Usage
💡 Want real-world examples? See EXAMPLES.md for patterns drawn from production applications: live scoring, progress tracking, background job output, and more.
In Your Views
Use turbo_stream_from exactly as you would with Action Cable:
<div>
<%= turbo_stream_from "counter_updates" %>
<span id="counter-value"><%= @counter.value %></span>
</div>In Your Models
Use the same broadcast methods you're familiar with:
class Counter < ApplicationRecord
def broadcast_update
broadcast_replace_later_to "counter_updates",
target: "counter-value",
html: "<span id='counter-value'>#{value}</span>"
end
endAvailable broadcast methods:
-
broadcast_replace_later_to/broadcast_replace_to -
broadcast_update_later_to/broadcast_update_to -
broadcast_append_later_to/broadcast_append_to -
broadcast_prepend_later_to/broadcast_prepend_to broadcast_remove_to
All methods support the same options as Turbo Streams:
-
target:- DOM element ID -
partial:- Render a partial -
html:- Use raw HTML -
locals:- Pass locals to partial
Example with Partial
class Score < ApplicationRecord
after_save do
broadcast_replace_later_to "live-scores",
partial: "scores/score",
target: dom_id(self)
end
endCustom JSON Broadcasting
For use cases that need structured data instead of HTML (progress bars, charts, interactive widgets), use TurboCable::Broadcastable.broadcast_json:
class OfflinePlaylistJob < ApplicationJob
def perform(user_id)
stream_name = "playlist_progress_#{user_id}"
# Broadcast JSON updates
TurboCable::Broadcastable.broadcast_json(stream_name, {
status: 'processing',
progress: 50,
message: 'Processing files...'
})
end
endJavaScript handling (in a Stimulus controller):
connect() {
document.addEventListener('turbo:stream-message', this.handleMessage.bind(this))
}
handleMessage(event) {
const { stream, data } = event.detail
if (stream === 'playlist_progress_123') {
console.log(data.progress) // 50
this.updateProgressBar(data.progress, data.message)
}
}The Stimulus controller automatically dispatches turbo:stream-message CustomEvents when receiving JSON data (non-HTML strings). See EXAMPLES.md for a complete working example with progress tracking.
Configuration
Broadcast URL (Optional)
By default, broadcasts use this port selection logic:
-
ENV['TURBO_CABLE_PORT']- if set, always use this port -
ENV['PORT']- if set and TURBO_CABLE_PORT is not set, use this port -
3000- default fallback
Configure these environment variables if needed:
# config/application.rb or initializer
# Override PORT when it's set to a proxy/foreman port (e.g., Thruster, foreman defaults to 5000)
ENV['TURBO_CABLE_PORT'] = '3000'
# Or specify the complete URL (overrides all port detection)
ENV['TURBO_CABLE_BROADCAST_URL'] = 'http://localhost:3000/_broadcast'When to set TURBO_CABLE_PORT:
-
Foreman/Overmind: These set
PORT=5000by default, but Rails runs on a different port -
Thruster/nginx proxy: When
PORTis set to the proxy port, not the Rails server port -
Never needed: When
PORTcorrectly points to your Rails server (like with Navigator/configurator.rb)
WebSocket URL (Optional)
By default, the Stimulus controller connects to ws://[current-host]/cable. For custom routing or multi-region deployments, you can specify the WebSocket URL via the standard Rails helper:
<!-- In your layout -->
<head>
<%= action_cable_meta_tag %>
</head>This generates <meta name="action-cable-url" content="..."> using your configured config.action_cable.url.
Example use cases:
-
Multi-region deployments: Route to region-specific endpoints (e.g.,
wss://example.com/regions/us-east/cable) - Reverse proxies: Use custom paths configured in your proxy (e.g., Navigator, nginx)
- Custom routing: Any non-standard WebSocket endpoint path
Configure the URL in your environment config:
# config/environments/production.rb
config.action_cable.url = "wss://example.com/regions/#{ENV['FLY_REGION']}/cable"The Stimulus controller will use this meta tag if present, otherwise fall back to the default /cable endpoint on the current host.
Migration from Action Cable
⚠️ First, verify your deployment architecture supports TurboCable. If you have multiple Rails instances serving the same app (Heroku dynos, AWS containers, Kubernetes pods, load-balanced VPS), TurboCable won't work for you. See "When NOT to Use" above.
If you're on a single server:
Views: No changes needed! turbo_stream_from works identically.
Models: No changes needed! All broadcast_* methods work identically.
Infrastructure: Just add the gem and run the installer. Action Cable, Redis, and Solid Cable can be removed.
Protocol Specification
WebSocket Messages (JSON)
Client → Server:
{"type": "subscribe", "stream": "counter_updates"}
{"type": "unsubscribe", "stream": "counter_updates"}
{"type": "pong"}Server → Client:
{"type": "subscribed", "stream": "counter_updates"}
{"type": "message", "stream": "counter_updates", "data": "<turbo-stream...>"}
{"type": "message", "stream": "progress", "data": {"status": "processing", "progress": 50}}
{"type": "ping"}The data field can contain either:
- String: Turbo Stream HTML (automatically processed as DOM updates)
-
Object: Custom JSON data (dispatched as
turbo:stream-messageevent)
Broadcast Endpoint
POST /_broadcast
Content-Type: application/json
# Turbo Stream HTML
{
"stream": "counter_updates",
"data": "<turbo-stream action=\"replace\" target=\"counter\">...</turbo-stream>"
}
# Custom JSON
{
"stream": "progress_updates",
"data": {"status": "processing", "progress": 50, "message": "Processing..."}
}How It Works
-
Rack Middleware: Intercepts
/cablerequests and upgrades to WebSocket -
Stimulus Controller: Discovers
turbo_stream_frommarkers and subscribes -
Broadcast Endpoint: Rails broadcasts via HTTP POST to
/_broadcast - WebSocket Distribution: Middleware forwards updates to subscribed clients
Critical architectural constraint: All components (WebSocket server, Rails app, broadcast endpoint) run in the same process. This is why cross-server broadcasting isn't supported.
Security
Broadcast Endpoint Protection
The /_broadcast endpoint is restricted to localhost only (127.0.0.0/8 and ::1). This prevents external attackers from broadcasting arbitrary HTML to connected clients.
Why this matters: An unprotected broadcast endpoint would allow XSS attacks - anyone who could POST to /_broadcast could inject malicious HTML into user browsers.
Why localhost-only is safe: Since TurboCable runs in-process with your Rails app, all broadcasts originate from the same machine. External access is never needed and would indicate an attack.
Network configuration: Ensure your firewall/reverse proxy doesn't forward external requests to /_broadcast. This endpoint should never be exposed through nginx, Apache, or any proxy.
Compatibility
- Rails: 7.0+ (tested with 8.0+)
- Ruby: 3.0+
- Browsers: All modern browsers with WebSocket support
-
Server: Puma or any Rack server that supports
rack.hijack
Technical Details
Action Cable Feature Differences
-
stream_fornot supported - Useturbo_stream_frominstead - Client→Server communication - Use standard HTTP requests (forms, fetch, Turbo Frames) instead of WebSocket channel actions
- In-process WebSocket server - Not a separate cable server; runs within Rails process
Hybrid Async/Sync Behavior
TurboCable intelligently chooses between async and sync broadcasting:
Methods with _later_to suffix (e.g., broadcast_replace_later_to):
- ✅ Async - If Active Job is configured with a non-inline adapter (Solid Queue, Sidekiq, etc.), broadcasts are enqueued as jobs
- 🔄 Sync fallback - If no job backend exists, broadcasts happen synchronously via HTTP POST
Methods without _later_to (e.g., broadcast_replace_to):
- 🔄 Always sync - Broadcasts happen immediately, useful for callbacks like
before_destroy
Why hybrid?
- Zero dependencies - Works out of the box without requiring a job backend
- Performance - Async when available prevents blocking HTTP responses
- Flexibility - Automatically adapts to your infrastructure
Example:
# Development (no job backend) - synchronous
counter.broadcast_replace_later_to "updates" # HTTP POST happens now
# Production (with Solid Queue) - asynchronous
counter.broadcast_replace_later_to "updates" # Job enqueued, returns immediatelyWhat IS Supported
- ✅ All Turbo Streams actions (replace, update, append, prepend, remove)
- ✅ Multiple concurrent connections per process
- ✅ Multiple streams per connection
- ✅ Partial rendering with locals
- ✅ Auto-reconnection on connection loss
- ✅ Thread-safe subscription management
Development
After checking out the repo:
bundle install
bundle exec rake testContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rubys/turbo_cable.
License
The gem is available as open source under the terms of the MIT License.
Credits
Inspired by the memory optimization needs of multi-region Rails deployments. Built to prove that Action Cable's functionality can be achieved with minimal dependencies and maximum efficiency.