action-cable-websocket-patterns
Complete guide to implementing real-time WebSocket features with Action Cable in Ruby on Rails. Use this skill when: (1) Building real-time chat applications, (2) Implementing live notifications and presence tracking, (3) Creating collaborative features, (4) Broadcasting model updates, (5) Setting up WebSocket authentication and authorization, (6) Testing Action Cable channels. Trigger keywords: Action Cable, WebSocket, real-time, channels, broadcasting, stream, subscriptions, presence, live updates, cable
When & Why to Use This Skill
This Claude skill provides a comprehensive implementation guide and best-practice patterns for integrating real-time WebSocket features using Action Cable in Ruby on Rails. It focuses on secure channel authorization, persistent broadcasting strategies, and efficient client-side integration to help developers build scalable real-time applications like chats, live dashboards, and collaborative tools.
Use Cases
- Building secure, real-time chat applications with private messaging and room-based broadcasting.
- Implementing live notification systems and presence tracking to monitor user online/offline status in real-time.
- Creating collaborative editing features or live data dashboards that automatically broadcast model updates to connected clients.
- Setting up robust WebSocket authentication and authorization to ensure secure data streaming and prevent unauthorized access.
- Developing automated test suites for Action Cable channels and connections using RSpec and system testing frameworks.
| name | "Action Cable & WebSocket Patterns" |
|---|---|
| description | "Complete guide to implementing real-time WebSocket features with Action Cable in Ruby on Rails. Use this skill when: (1) Building real-time chat applications, (2) Implementing live notifications and presence tracking, (3) Creating collaborative features, (4) Broadcasting model updates, (5) Setting up WebSocket authentication and authorization, (6) Testing Action Cable channels. Trigger keywords: Action Cable, WebSocket, real-time, channels, broadcasting, stream, subscriptions, presence, live updates, cable" |
Action Cable & WebSocket Patterns Skill
This skill provides comprehensive guidance for implementing real-time WebSocket features with Action Cable in Rails applications.
When to Use This Skill
- Creating real-time chat features
- Implementing live notifications
- Building presence tracking (who's online)
- Broadcasting model updates
- Collaborative editing features
- Live dashboards and analytics
- Setting up channel authorization
- Testing WebSocket functionality
External Documentation
Official Guides: https://guides.rubyonrails.org/action_cable_overview.html API Documentation: https://api.rubyonrails.org/classes/ActionCable.html
# Always check official documentation for:
# - Action Cable Overview: https://guides.rubyonrails.org/action_cable_overview.html
# - Channel API: https://api.rubyonrails.org/classes/ActionCable/Channel/Base.html
# - Streams API: https://api.rubyonrails.org/classes/ActionCable/Channel/Streams.html
# - Testing: https://guides.rubyonrails.org/testing.html#testing-action-cable
Pre-Work Inspection
# Check existing channels
ls app/channels/ 2>/dev/null
# Check channel naming conventions
find app/channels -name '*_channel.rb' -exec head -20 {} \; | head -50
# Check Action Cable configuration
cat config/cable.yml 2>/dev/null
cat config/environments/production.rb | grep -A 5 'action_cable' 2>/dev/null
# Check for existing broadcasting patterns
grep -r 'ActionCable.server.broadcast\|broadcast_to' app/ --include='*.rb' | head -10
# Check JavaScript/TypeScript consumer setup
find app/javascript -name 'cable.js' -o -name 'consumer.js' 2>/dev/null
Core Principles
1. Authorization First (CRITICAL)
ALWAYS authorize in subscribed method - reject unauthorized users explicitly.
# WRONG - No authorization (security vulnerability!)
class PrivateChannel < ApplicationCable::Channel
def subscribed
stream_from "private_data" # Anyone can subscribe!
end
end
# CORRECT - Explicit authorization
class PrivateChannel < ApplicationCable::Channel
def subscribed
reject unless current_user
reject unless current_user.admin?
stream_from "private_data"
end
end
2. Persist First, Broadcast Second
Never rely on broadcasts alone - clients may disconnect before receiving data.
# WRONG - Only broadcast (data lost if client offline)
def create_message(data)
ActionCable.server.broadcast(
"chat_#{params[:room_id]}",
message: data['text'] # Lost forever if client disconnected!
)
end
# CORRECT - Persist then broadcast
def create_message(data)
message = Message.create!(
room_id: params[:room_id],
user: current_user,
text: data['text']
)
# Now broadcast (clients can fetch from DB if missed)
ActionCable.server.broadcast(
"chat_#{params[:room_id]}",
message: message
)
end
3. Use stream_for for Model Broadcasting
Prefer stream_for over stream_from for model-based streams.
# WRONG - Manual broadcasting names (error-prone)
stream_from "posts:#{params[:id]}"
ActionCable.server.broadcast("posts:#{@post.id}", data) # Typo-prone!
# CORRECT - Type-safe model broadcasting
stream_for @post
PostsChannel.broadcast_to(@post, data) # Automatic naming
Channel Structure Template
# app/channels/notifications_channel.rb
class NotificationsChannel < ApplicationCable::Channel
# Lifecycle callbacks (optional)
before_subscribe :authenticate_user
after_subscribe :log_subscription
before_unsubscribe :cleanup_presence
after_unsubscribe :log_unsubscription
def subscribed
# 1. Authorization (REQUIRED)
reject unless current_user
# 2. Subscribe to personal stream
stream_from "notifications_#{current_user.id}"
# 3. Mark user as online (optional)
mark_online
end
def unsubscribed
# Cleanup when client disconnects
mark_offline
end
# Client-initiated action: channel.perform('mark_as_read', {id: 123})
def mark_as_read(data)
notification = current_user.notifications.find(data['id'])
notification.mark_as_read!
# Broadcast updated count to user
ActionCable.server.broadcast(
"notifications_#{current_user.id}",
action: 'count_updated',
unread_count: current_user.notifications.unread.count
)
end
private
def authenticate_user
reject unless current_user
end
def log_subscription
Rails.logger.info "User #{current_user.id} subscribed to notifications"
end
def cleanup_presence
PresenceService.mark_offline(current_user)
end
def mark_online
PresenceService.mark_online(current_user)
end
def mark_offline
PresenceService.mark_offline(current_user)
end
end
Stream Patterns
Pattern 1: Personal User Streams
class UserChannel < ApplicationCable::Channel
def subscribed
reject unless current_user
# Each user gets their own stream
stream_from "user_#{current_user.id}"
end
end
# Broadcasting from elsewhere
ActionCable.server.broadcast(
"user_#{user.id}",
type: 'notification',
message: 'You have a new message'
)
Pattern 2: Model-based Streams
class PostChannel < ApplicationCable::Channel
def subscribed
post = Post.find(params[:id])
reject unless post.published? || post.author == current_user
# Generates stream name: "posts:Post:123"
stream_for post
end
end
# Broadcasting from model
class Post < ApplicationRecord
after_update_commit { broadcast_changes }
private
def broadcast_changes
PostChannel.broadcast_to(
self,
action: 'updated',
post: self.as_json(only: [:id, :title, :body])
)
end
end
Pattern 3: Room/Group Streams
class ChatChannel < ApplicationCable::Channel
def subscribed
room = Room.find(params[:room_id])
reject unless room.member?(current_user)
stream_from "chat_room_#{room.id}"
end
def speak(data)
room = Room.find(params[:room_id])
message = room.messages.create!(
user: current_user,
text: data['text']
)
ActionCable.server.broadcast(
"chat_room_#{room.id}",
action: 'new_message',
message: message,
user: current_user.as_json(only: [:id, :name])
)
end
end
Pattern 4: Presence Tracking
class PresenceChannel < ApplicationCable::Channel
def subscribed
reject unless current_user
# Subscribe to room presence
@room_id = params[:room_id]
stream_from "presence_room_#{@room_id}"
# Add user to room
add_to_room
end
def unsubscribed
remove_from_room
end
def heartbeat
# Client sends periodic heartbeat
Redis.current.setex(
"presence:user:#{current_user.id}:room:#{@room_id}",
30, # 30 second TTL
Time.current.to_i
)
end
private
def add_to_room
Redis.current.sadd("room:#{@room_id}:members", current_user.id)
# Broadcast updated member list
ActionCable.server.broadcast(
"presence_room_#{@room_id}",
action: 'user_joined',
user: current_user.as_json(only: [:id, :name]),
member_count: room_member_count
)
end
def remove_from_room
Redis.current.srem("room:#{@room_id}:members", current_user.id)
ActionCable.server.broadcast(
"presence_room_#{@room_id}",
action: 'user_left',
user_id: current_user.id,
member_count: room_member_count
)
end
def room_member_count
Redis.current.scard("room:#{@room_id}:members")
end
end
Broadcasting Strategies
From Controllers (Synchronous)
class CommentsController < ApplicationController
def create
@comment = @post.comments.create!(comment_params)
# Broadcast immediately after creation
CommentsChannel.broadcast_to(
@post,
action: 'comment_created',
comment: @comment.as_json,
total_count: @post.comments.count
)
render json: @comment, status: :created
end
end
From Models (After Commit)
class Notification < ApplicationRecord
belongs_to :user
# Use after_commit to ensure transaction completes
after_create_commit { broadcast_notification }
private
def broadcast_notification
ActionCable.server.broadcast(
"notifications_#{user_id}",
action: 'notification_created',
notification: self.as_json,
unread_count: user.notifications.unread.count
)
end
end
From Background Jobs (Async)
class BroadcastUpdateJob < ApplicationJob
queue_as :broadcasts
def perform(model_class, model_id, action, data = {})
model = model_class.constantize.find(model_id)
channel_class = "#{model_class}Channel".constantize
channel_class.broadcast_to(
model,
action: action,
data: data
)
end
end
# Usage
BroadcastUpdateJob.perform_later('Post', post.id, 'updated', { likes_count: post.likes.count })
Connection Authentication
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 authentication (default Rails)
if verified_user = User.find_by(id: cookies.encrypted[:user_id])
verified_user
# Token-based authentication (for API clients)
elsif verified_user = find_user_from_token
verified_user
else
reject_unauthorized_connection
end
end
def find_user_from_token
# Extract token from query params or headers
token = request.params[:token] || extract_token_from_headers
return nil unless token
# Verify JWT or session token
payload = JWT.decode(token, Rails.application.secret_key_base).first
User.find_by(id: payload['user_id'])
rescue JWT::DecodeError
nil
end
def extract_token_from_headers
# Extract from Authorization header if present
if request.headers['Authorization'].present?
request.headers['Authorization'].split(' ').last
end
end
end
end
Client-Side Integration (JavaScript)
Consumer Setup
// app/javascript/channels/consumer.js
import { createConsumer } from "@rails/actioncable"
export default createConsumer()
Channel Subscription
// app/javascript/channels/notifications_channel.js
import consumer from "./consumer"
consumer.subscriptions.create("NotificationsChannel", {
connected() {
console.log("Connected to notifications channel")
},
disconnected() {
console.log("Disconnected from notifications channel")
},
received(data) {
// Handle received data
console.log("Received:", data)
switch(data.action) {
case 'notification_created':
this.showNotification(data.notification)
this.updateBadge(data.unread_count)
break
case 'count_updated':
this.updateBadge(data.unread_count)
break
}
},
// Client-initiated actions
markAsRead(notificationId) {
this.perform('mark_as_read', { id: notificationId })
},
showNotification(notification) {
// Display notification in UI
},
updateBadge(count) {
// Update unread count badge
}
})
Parametrized Channels
// app/javascript/channels/chat_channel.js
import consumer from "./consumer"
const roomId = document.getElementById('room-id').value
consumer.subscriptions.create({ channel: "ChatChannel", room_id: roomId }, {
received(data) {
if (data.action === 'new_message') {
this.appendMessage(data.message)
}
},
speak(message) {
this.perform('speak', { text: message })
},
appendMessage(message) {
const messagesEl = document.getElementById('messages')
messagesEl.insertAdjacentHTML('beforeend', `
<div class="message">
<strong>${message.user.name}:</strong> ${message.text}
</div>
`)
}
})
Testing Action Cable
Channel Specs (RSpec)
# spec/channels/notifications_channel_spec.rb
require 'rails_helper'
RSpec.describe NotificationsChannel, type: :channel do
let(:user) { create(:user) }
before do
# Stub the connection with current_user
stub_connection(current_user: user)
end
describe '#subscribed' do
it 'subscribes to user notification stream' do
subscribe
expect(subscription).to be_confirmed
expect(subscription).to have_stream_from("notifications_#{user.id}")
end
it 'rejects unauthenticated users' do
stub_connection(current_user: nil)
subscribe
expect(subscription).to be_rejected
end
end
describe '#mark_as_read' do
let!(:notification) { create(:notification, user: user) }
it 'marks notification as read' do
subscribe
perform :mark_as_read, id: notification.id
expect(notification.reload).to be_read
end
it 'broadcasts updated count' do
subscribe
expect {
perform :mark_as_read, id: notification.id
}.to have_broadcasted_to("notifications_#{user.id}").with(
action: 'count_updated',
unread_count: 0
)
end
end
end
Connection Specs
# spec/channels/application_cable/connection_spec.rb
require 'rails_helper'
RSpec.describe ApplicationCable::Connection, type: :channel do
let(:user) { create(:user) }
it 'successfully connects with valid user' do
cookies.encrypted[:user_id] = user.id
connect '/cable'
expect(connection.current_user).to eq(user)
end
it 'rejects connection without user' do
expect { connect '/cable' }.to have_rejected_connection
end
it 'successfully connects with valid token' do
token = generate_jwt_token(user)
connect "/cable?token=#{token}"
expect(connection.current_user).to eq(user)
end
end
Integration Testing
# spec/system/chat_spec.rb
require 'rails_helper'
RSpec.describe 'Chat functionality', type: :system, js: true do
let(:user) { create(:user) }
let(:room) { create(:room) }
before do
sign_in user
room.add_member(user)
end
it 'receives messages in real-time' do
visit room_path(room)
# Simulate another user sending a message
ChatChannel.broadcast_to(
room,
action: 'new_message',
message: { text: 'Hello from test!', user: { name: 'Test User' } }
)
# Verify message appears
expect(page).to have_content('Hello from test!')
end
end
Performance Optimization
1. Filter Broadcast Data
# WRONG - Broadcasting full objects
ActionCable.server.broadcast(
"posts",
post: @post # Serializes entire object with all attributes!
)
# CORRECT - Only broadcast what's needed
ActionCable.server.broadcast(
"posts",
post: @post.as_json(only: [:id, :title, :updated_at])
)
2. Use Redis Adapter in Production
# config/cable.yml
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: myapp_production
3. Batch Broadcasts
# WRONG - N broadcasts in loop
posts.each do |post|
ActionCable.server.broadcast("posts", post: post)
end
# CORRECT - Single broadcast with batch
ActionCable.server.broadcast(
"posts",
action: 'batch_update',
posts: posts.as_json(only: [:id, :title])
)
Common Pitfalls
1. Missing Authorization
# WRONG - No authorization check
def subscribed
stream_from "admin_channel" # Anyone can subscribe!
end
# CORRECT - Explicit authorization
def subscribed
reject unless current_user&.admin?
stream_from "admin_channel"
end
2. Broadcasting Before Persistence
# WRONG - Broadcast before save (transaction may rollback)
def create_post
post = Post.new(params)
ActionCable.server.broadcast("posts", post: post) # Not saved yet!
post.save!
end
# CORRECT - Use after_commit callback
class Post < ApplicationRecord
after_create_commit { broadcast_creation }
def broadcast_creation
ActionCable.server.broadcast("posts", post: self)
end
end
3. Memory Leaks from Subscriptions
// WRONG - Creating new subscription on every render
function ChatComponent() {
const subscription = consumer.subscriptions.create("ChatChannel", {
received(data) { /* ... */ }
})
// Subscription never cleaned up!
}
// CORRECT - Cleanup on unmount
function ChatComponent() {
useEffect(() => {
const subscription = consumer.subscriptions.create("ChatChannel", {
received(data) { /* ... */ }
})
return () => subscription.unsubscribe() // Cleanup
}, [])
}
Production Deployment
Nginx Configuration
# nginx.conf
upstream cable {
server localhost:28080;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /cable {
proxy_pass http://cable;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
}
Environment Variables
# config/environments/production.rb
config.action_cable.url = ENV.fetch('ACTION_CABLE_URL') { 'wss://example.com/cable' }
config.action_cable.allowed_request_origins = [
'https://example.com',
'https://www.example.com'
]
# Mount Action Cable on separate server (recommended)
config.action_cable.mount_path = nil # Don't mount in main app
Anti-Patterns to Avoid
❌ NEVER:
- Skip authorization in
subscribed - Broadcast before database commit
- Send sensitive data without filtering
- Use Action Cable for HTTP request/response patterns
- Create subscriptions without cleanup
- Broadcast large objects (>1KB)
- Use channels for batch processing
✅ INSTEAD:
- Always use
reject unlessfor authorization - Use
after_commitcallbacks for broadcasts - Filter data with
as_json(only: [...]) - Use regular HTTP endpoints for request/response
- Always unsubscribe on component unmount
- Only broadcast IDs and essential fields
- Use Sidekiq jobs for batch processing
Key Takeaways
- Authorization is mandatory - Always reject unauthorized users
- Persist first, broadcast second - Don't rely on broadcasts alone
- Use
stream_forfor models - Type-safe, conventional broadcasting - Filter broadcast data - Only send what clients need
- Test authorization thoroughly - Security vulnerabilities are common
- Clean up subscriptions - Prevent memory leaks on client
- Use Redis in production - Required for multi-process deployments