action-cable
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[: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 channelapp/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
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.
# 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 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-sidereceivemethod)
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_toon the model - Unauthenticated connections — always implement
connectwithreject_unauthorized_connection - Broadcasting entire models — send minimal data or pre-rendered HTML, not
model.as_json - No
unsubscribedcleanup — if you track state insubscribed, clean it up inunsubscribed - Synchronous heavy work in channels — use background jobs for expensive broadcasts
- Using
asyncadapter 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 channelsets 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 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
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32caching
Expert guidance for Rails caching — fragment caching, Russian doll caching, cache keys/versioning, low-level caching (Rails.cache), conditional GET (stale?/fresh_when), and cache stores (Solid Cache, Redis, Memcached). Use when implementing cache, caching, fragment cache, Russian doll, Rails.cache, Solid Cache, cache key, HTTP caching, stale?, fresh_when, cache store, or optimizing performance.
4uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4