npx skills add ...
npx skills add thinkoodle/rails-skills --skill action-cable
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.
npx skills add thinkoodle/rails-skills --skill action-cable
Build real-time WebSocket features in Rails using Action Cable — channels, subscriptions, broadcasting, and Solid Cable.
Before writing ANY Action Cable code, ask: would Turbo Streams solve this?
received() callback logic beyond DOM replacementperform() / send()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.
Match existing project conventions. If the app already uses Turbo Streams broadcasting, don't introduce raw Action Cable for something Turbo handles.
ALWAYS authenticate connections. Never skip this.
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.
Use the generator:
This creates:
app/channels/chat_channel.rb — server-side channelapp/javascript/channels/chat_channel.js — client-side subscription (if using importmap/jsbundling)Channel with streams:
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.
Key client callbacks:
connected() — subscription confirmed, WebSocket opendisconnected() — WebSocket closed (network issue, server restart)received(data) — server sent data via broadcastrejected() — server rejected the subscriptionthis.perform("action", data) — call server-side channel methodthis.send(data) — send raw data (requires server-side receive method)From models/controllers/jobs:
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:
Rails 8 default: Solid Cable (database-backed, no Redis needed)
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:
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.
Add the meta tag to your layout:
Server-side cleanup:
Client-side reconnection is automatic. Action Cable reconnects with exponential backoff. But handle the UI state:
Common mistake: Not implementing unsubscribed when tracking presence or holding resources. Always clean up.
Channel test (unit):
See references/testing.md for connection tests, stream_for assertions, and broadcast assertion patterns.
See references/client-side.md for the full Stimulus + Action Cable integration pattern with controller example.
broadcasts_to on the modelconnect with reject_unauthorized_connectionmodel.as_jsonunsubscribed cleanup — if you track state in subscribed, clean it up in unsubscribedasync adapter in production — it's single-process only; use Solid Cable or Redisallowed_request_origins — required in production to prevent cross-origin hijackingstream_from "chat_#{params[:room]}"disconnected() on client — users need visual feedback when connection dropsbin/rails generate channel sets up both server and client files correctlySee 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 streamsreferences/broadcasting.md — Broadcasting from models, controllers, targeted/conditionalreferences/client-side.md — Stimulus integration, reconnection, Turbo Streams comparisonreferences/adapters-config.md — Solid Cable, Redis, PostgreSQL config, deploymentreferences/testing.md — Channel, connection, broadcast, and system test patternsreferences/patterns.md — Presence, typing indicators, progress tracking, error handling, security# 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
endbin/rails generate channel Chat# 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# 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 })// 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 })
}
}
)# 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
})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
endbin/rails solid_cable:installdevelopment:
adapter: async
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day# 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<!-- app/views/layouts/application.html.erb -->
<head>
<%= action_cable_meta_tag %>
</head>class PresenceChannel < ApplicationCable::Channel
def subscribed
stream_from "presence"
current_user.update!(online: true)
end
def unsubscribed
current_user.update!(online: false)
end
endconnected() {
document.getElementById("status").textContent = "Connected"
},
disconnected() {
document.getElementById("status").textContent = "Reconnecting..."
}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
endbin/rails generate channel ChannelName [action1 action2]
# Example:
bin/rails generate channel Chat speak# 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// 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# 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