| name | action-cable |
| description | Expert guidance for implementing real-time features with Action Cable in Rails applications. Use when working with websockets, channels, subscriptions, broadcasting, live updates, push notifications, or Solid Cable. Covers connection authentication, channel creation, streaming, client-side JS, testing, and deployment. Clarifies when to use Action Cable directly vs Turbo Streams broadcasting. |
| allowed-tools | Read, Grep, Glob, Write, Edit, Bash(bin/rails generate channel:*), Bash(bin/rails generate channel), Bash(bin/rails solid_cable:install), Bash(bundle exec rails generate channel) |
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
class Message < ApplicationRecord
broadcasts_to :room
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
cat config/cable.yml
ls app/channels/application_cable/
ls app/channels/
ls app/javascript/channels/ 2>/dev/null
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.
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
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[:user_id] 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 reject_unauthorized_connection. 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:
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_#{params[:room_id]}"
end
def unsubscribed
end
def speak(data)
Message.create!(
room_id: params[:room_id],
user: current_user,
body: data["body"]
)
end
end
Step 4: Choose Stream Type
stream_from vs stream_for:
| Method | Use When | Stream Name |
|---|
stream_from "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 stream_for when the stream maps to a model. It's safer (uses GlobalID, not guessable IDs) and pairs naturally with broadcast_to.
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end
NotificationsChannel.broadcast_to(user, { title: "New message", body: "..." })
class DashboardChannel < ApplicationCable::Channel
def subscribed
stream_from "dashboard_#{params[:team_id]}"
end
end
ActionCable.server.broadcast("dashboard_#{team.id}", { metric: "users", value: 42 })
Step 5: Client-Side Subscription
import { createConsumer } from "@rails/actioncable"
export default createConsumer()
import consumer from "./consumer"
const chatChannel = consumer.subscriptions.create(
{ channel: "ChatChannel", room_id: roomId },
{
connected() {
console.log("Connected to chat")
},
disconnected() {
console.log("Disconnected from chat")
},
received(data) {
const messagesEl = document.getElementById("messages")
messagesEl.insertAdjacentHTML("beforeend", data.html)
},
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:
ActionCable.server.broadcast("chat_#{room.id}", {
html: render_to_string(partial: "messages/message", locals: { message: message }),
sent_by: message.user.name
})
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
Rails.application.configure do
config.action_cable.mount_path = "/cable"
config.action_cable.allowed_request_origins = [
"https://yourdomain.com",
%r{https://.*\.yourdomain\.com}
]
config.action_cable.worker_pool_size = 4
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]
bin/rails generate channel Chat speak
Server-Side API
stream_from "stream_name"
stream_for model_instance
ActionCable.server.broadcast("stream_name", data)
MyChannel.broadcast_to(model_instance, data)
identified_by :current_user
current_user
reject
reject_unauthorized_connection
ActionCable.server.remote_connections.where(current_user: user).disconnect
Client-Side API
const sub = consumer.subscriptions.create(
{ channel: "ChatChannel", room_id: 1 },
{ connected() {}, disconnected() {}, received(data) {} }
)
sub.perform("speak", { body: "Hello" })
sub.send({ body: "Hello" })
sub.unsubscribe()
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 reject_unauthorized_connection
- 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
allowed_request_origins — required in production to prevent cross-origin hijacking
- String interpolation with user input in stream names — validate/sanitize params before
stream_from "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
ActionCable.server.connections.length
config.action_cable.log_tags = [
-> request { request.env["user_account_id"] || "no-account" },
:action_cable,
-> request { request.uuid }
]
import * as ActionCable from "@rails/actioncable"
ActionCable.logger.enabled = true
consumer.connection.isOpen()
consumer.connection.isActive()
cat config/cable.yml
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