Rails Action Cable Expert
Build real-time WebSocket features in Rails using Action Cable — channels, subscriptions, broadcasting, and Solid Cable.
Critical Decision: Action Cable vs Turbo Streams
Before writing ANY Action Cable code, ask: would Turbo Streams solve this?
Use Turbo Streams Broadcasting (NOT raw Action Cable) When:
- Broadcasting model changes (create/update/destroy) to update page fragments
- Simple "something changed, update this part of the page" scenarios
- CRUD-driven real-time updates (chat messages, comments, notifications list)
- You want automatic DOM updates without writing JavaScript
# This is Turbo Streams — NOT raw Action Cable. Prefer this.
class Message < ApplicationRecord
broadcasts_to :room # That's it. No channel file needed.
end
Use Raw Action Cable When:
- Custom client-side behavior — you need
received() callback logic beyond DOM replacement
- Bidirectional communication — client sends data TO server via
perform() / send()
- Presence/typing indicators — tracking who's online, who's typing
- Non-HTML payloads — sending JSON data, coordinates, game state
- Progress tracking — file upload progress, job status updates
- External integrations — piping data from external WebSocket sources
- Fine-grained stream control — dynamic subscribe/unsubscribe based on user actions
Rule of thumb: If you're just broadcasting HTML to replace/append DOM elements after a model callback, use Turbo Streams. If you need custom JavaScript handling or bidirectional communication, use Action Cable.
When To Use This Skill
- Setting up WebSocket connections and channels
- Implementing real-time features (chat, notifications, presence, live dashboards)
- Configuring Solid Cable, Redis, or PostgreSQL adapters
- Authenticating WebSocket connections
- Writing client-side channel subscriptions
- Testing Action Cable channels
- Deploying Action Cable in production
Instructions
Step 1: Check Existing Setup
# Check if Action Cable is already configured
cat config/cable.yml
ls app/channels/application_cable/
# Check for existing channels
ls app/channels/
ls app/javascript/channels/ 2>/dev/null
# Check if Turbo is present (prefer Turbo Streams if so)
grep -r "turbo-rails" Gemfile
grep -r "broadcasts" app/models/
Match existing project conventions. If the app already uses Turbo Streams broadcasting, don't introduce raw Action Cable for something Turbo handles.
Step 2: Set Up Connection Authentication
ALWAYS authenticate connections. Never skip this.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# Cookie-based auth (most common with Devise/standard Rails auth)
if verified_user = User.find_by(id: cookies.encrypted[:user_id])
verified_user
else
reject_unauthorized_connection
end
end
end
end
Auth patterns by setup:
| Auth Method |
Implementation |
| Cookie/session (default) |
cookies.encrypted[:userid] or cookies.encrypted["session"]["user_id"] |
| Devise |
env["warden"].user or cookies.signed["user.id"] |
| Token-based (API) |
Pass token via query param: createConsumer(\/cable?token=\${token}\) |
Common mistake: Leaving connect empty or not calling rejectunauthorizedconnection. This lets unauthenticated users subscribe to any channel.
Step 3: Create Channels
Use the generator:
bin/rails generate channel Chat
This creates:
app/channels/chat_channel.rb — server-side channel
app/javascript/channels/chat_channel.js — client-side subscription (if using importmap/jsbundling)
- Test file
Channel with streams:
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
# stream_from — string-based stream name (manual)
stream_from "chat_#{params[:room_id]}"
# OR stream_for — model-based stream name (preferred for model association)
# room = Room.find(params[:room_id])
# stream_for room
end
def unsubscribed
# Cleanup when client disconnects
end
# Custom actions callable from client via perform()
def speak(data)
Message.create!(
room_id: params[:room_id],
user: current_user,
body: data["body"]
)
end
end
Step 4: Choose Stream Type
streamfrom vs streamfor:
| Method |
Use When |
Stream Name |
streamfrom "chat#{id}" |
Manual string control, non-model streams |
You define it |
stream_for room |
Model-based streams, pairs with broadcast_to |
Auto-generated from GlobalID |
Prefer streamfor when the stream maps to a model. It's safer (uses GlobalID, not guessable IDs) and pairs naturally with broadcastto.
# stream_for + broadcast_to (preferred for model streams)
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end
# Broadcasting from anywhere:
NotificationsChannel.broadcast_to(user, { title: "New message", body: "..." })
# stream_from + broadcast (for non-model or shared streams)
class DashboardChannel < ApplicationCable::Channel
def subscribed
stream_from "dashboard_#{params[:team_id]}"
end
end
# Broadcasting from anywhere:
ActionCable.server.broadcast("dashboard_#{team.id}", { metric: "users", value: 42 })
Step 5: Client-Side Subscription
// app/javascript/channels/consumer.js
import { createConsumer } from "@rails/actioncable"
export default createConsumer()
// app/javascript/channels/chat_channel.js
import consumer from "./consumer"
const chatChannel = consumer.subscriptions.create(
{ channel: "ChatChannel", room_id: roomId },
{
// Called when subscription is confirmed by server
connected() {
console.log("Connected to chat")
},
// Called when WebSocket closes
disconnected() {
console.log("Disconnected from chat")
},
// Called when server broadcasts data
received(data) {
const messagesEl = document.getElementById("messages")
messagesEl.insertAdjacentHTML("beforeend", data.html)
},
// Custom method — calls server-side ChatChannel#speak
speak(body) {
this.perform("speak", { body: body })
}
}
)
Key client callbacks:
connected() — subscription confirmed, WebSocket open
disconnected() — WebSocket closed (network issue, server restart)
received(data) — server sent data via broadcast
rejected() — server rejected the subscription
this.perform("action", data) — call server-side channel method
this.send(data) — send raw data (requires server-side receive method)
Step 6: Broadcasting
From models/controllers/jobs:
# Using broadcast (string stream name)
ActionCable.server.broadcast("chat_#{room.id}", {
html: render_to_string(partial: "messages/message", locals: { message: message }),
sent_by: message.user.name
})
# Using broadcast_to (model stream — pairs with stream_for)
ChatChannel.broadcast_to(room, {
html: render_to_string(partial: "messages/message", locals: { message: message }),
sent_by: message.user.name
})
Common mistake: Broadcasting too much data. Send only what the client needs. Prefer rendered HTML partials or minimal JSON — not entire model attributes with associations.
Broadcast from a background job for heavy rendering:
class MessageBroadcastJob < ApplicationJob
def perform(message)
html = ApplicationController.renderer.render(
partial: "messages/message",
locals: { message: message }
)
ActionCable.server.broadcast("chat_#{message.room_id}", { html: html })
end
end
Step 7: Configure the Adapter
Rails 8 default: Solid Cable (database-backed, no Redis needed)
bin/rails solid_cable:install
This sets up config/cable.yml and creates db/cable_schema.rb. Update config/database.yml to add the cable database.
config/cable.yml — quick setup:
development:
adapter: async
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
Adapter options: Solid Cable (default, database-backed), Redis (highest throughput), PostgreSQL (8KB payload limit). See references/adapters-config.md for full adapter comparison and configuration examples.
Step 8: Production Configuration
# config/environments/production.rb
Rails.application.configure do
# Mount path (default: /cable)
config.action_cable.mount_path = "/cable"
# Allowed origins (REQUIRED in production)
config.action_cable.allowed_request_origins = [
"https://yourdomain.com",
%r{https://.*\.yourdomain\.com}
]
# Worker pool (match to available DB connections)
config.action_cable.worker_pool_size = 4
# URL for standalone cable server (if separated)
# config.action_cable.url = "wss://cable.yourdomain.com"
end
Add the meta tag to your layout:
<!-- app/views/layouts/application.html.erb -->
<head>
<%= action_cable_meta_tag %>
</head>
Step 9: Handle Disconnections
Server-side cleanup:
class PresenceChannel < ApplicationCable::Channel
def subscribed
stream_from "presence"
current_user.update!(online: true)
end
def unsubscribed
current_user.update!(online: false)
end
end
Client-side reconnection is automatic. Action Cable reconnects with exponential backoff. But handle the UI state:
connected() {
document.getElementById("status").textContent = "Connected"
},
disconnected() {
document.getElementById("status").textContent = "Reconnecting..."
}
Common mistake: Not implementing unsubscribed when tracking presence or holding resources. Always clean up.
Step 10: Testing
Channel test (unit):
class ChatChannelTest < ActionCable::Channel::TestCase
test "subscribes to room stream" do
subscribe room_id: rooms(:general).id
assert subscription.confirmed?
assert_has_stream "chat_#{rooms(:general).id}"
end
test "rejects without room_id" do
subscribe room_id: nil
assert subscription.rejected?
end
end
See references/testing.md for connection tests, stream_for assertions, and broadcast assertion patterns.
Quick Reference
Generator
bin/rails generate channel ChannelName [action1 action2]
# Example:
bin/rails generate channel Chat speak
Server-Side API
# Stream methods (in subscribed)
stream_from "stream_name" # String-based stream
stream_for model_instance # Model-based stream (GlobalID)
# Broadcasting (from anywhere)
ActionCable.server.broadcast("stream_name", data) # To string stream
MyChannel.broadcast_to(model_instance, data) # To model stream
# Connection identifiers
identified_by :current_user # In Connection class
current_user # Available in channels
# Reject
reject # Reject subscription (in subscribed)
reject_unauthorized_connection # Reject connection (in connect)
# Disconnect a user (from anywhere)
ActionCable.server.remote_connections.where(current_user: user).disconnect
Client-Side API
// Create subscription
const sub = consumer.subscriptions.create(
{ channel: "ChatChannel", room_id: 1 },
{ connected() {}, disconnected() {}, received(data) {} }
)
// Call server action
sub.perform("speak", { body: "Hello" })
// Send raw data (needs server `receive` method)
sub.send({ body: "Hello" })
// Unsubscribe
sub.unsubscribe()
// Enable logging
ActionCable.logger.enabled = true
Stimulus + Action Cable Pattern
See references/client-side.md for the full Stimulus + Action Cable integration pattern with controller example.
Anti-Patterns to Avoid
- Using Action Cable when Turbo Streams suffices — if you just need model-change broadcasts with DOM updates, use
broadcasts_to on the model
- Unauthenticated connections — always implement
connect with rejectunauthorizedconnection
- Broadcasting entire models — send minimal data or pre-rendered HTML, not
model.as_json
- No
unsubscribed cleanup — if you track state in subscribed, clean it up in unsubscribed
- Synchronous heavy work in channels — use background jobs for expensive broadcasts
- Using
async adapter in production — it's single-process only; use Solid Cable or Redis
- Missing
allowedrequestorigins — required in production to prevent cross-origin hijacking
- String interpolation with user input in stream names — validate/sanitize params before
streamfrom "chat#{params[:room]}"
- Not handling
disconnected() on client — users need visual feedback when connection drops
- Creating channels without the generator —
bin/rails generate channel sets up both server and client files correctly
Debugging Tips
# Server-side: check active connections
ActionCable.server.connections.length
# Server-side: log tags for debugging
# config/environments/development.rb
config.action_cable.log_tags = [
-> request { request.env["user_account_id"] || "no-account" },
:action_cable,
-> request { request.uuid }
]
// Client-side: enable logging
import * as ActionCable from "@rails/actioncable"
ActionCable.logger.enabled = true
// Check connection state
consumer.connection.isOpen()
consumer.connection.isActive()
# Check cable config
cat config/cable.yml
# Verify WebSocket endpoint
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: test" \
http://localhost:3000/cable
See the references/ directory for detailed patterns, edge cases, and advanced configurations:
references/connection-auth.md — Authentication patterns (Devise, token, JWT, multi-identifier)
references/channels.md — Channel patterns, callbacks, dynamic streams
references/broadcasting.md — Broadcasting from models, controllers, targeted/conditional
references/client-side.md — Stimulus integration, reconnection, Turbo Streams comparison
references/adapters-config.md — Solid Cable, Redis, PostgreSQL config, deployment
references/testing.md — Channel, connection, broadcast, and system test patterns
references/patterns.md — Presence, typing indicators, progress tracking, error handling, security