home / skills / kaakati / rails-enterprise-dev / action-cable-patterns

This skill helps you implement secure, real-time collaboration with Action Cable by enforcing authorization, persistence, and model-level broadcasting.

npx playbooks add skill kaakati/rails-enterprise-dev --skill action-cable-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (4)
SKILL.md
6.7 KB
---
name: "Action Cable & WebSocket Patterns"
description: "Real-time WebSocket features with Action Cable in Rails. Use when: (1) Building real-time chat, (2) Live notifications/presence, (3) Broadcasting model updates, (4) WebSocket authorization. Trigger keywords: Action Cable, WebSocket, real-time, channels, broadcasting, stream, subscriptions, presence, cable"
version: 1.1.0
---

# Action Cable Patterns

Real-time WebSocket features for Rails applications.

## Real-Time Feature Decision Tree

```
What real-time feature?
│
├─ User notifications
│   └─ Personal stream: stream_from "notifications_#{current_user.id}"
│
├─ Chat room messages
│   └─ Group stream: stream_from "chat_room_#{room.id}"
│
├─ Model updates (live editing)
│   └─ Model stream: stream_for @post (with broadcast_to)
│
├─ Presence tracking (who's online)
│   └─ Presence stream + Redis: stream_from "presence_room_#{room.id}"
│
└─ Dashboard/analytics
    └─ Scoped stream: stream_from "dashboard_#{account.id}"
```

---

## Core Principles (CRITICAL)

### 1. Authorization First

```ruby
# WRONG - Security vulnerability!
def subscribed
  stream_from "private_data"  # Anyone can subscribe!
end

# RIGHT - Explicit authorization
def subscribed
  reject unless current_user
  reject unless current_user.can_access?(params[:resource_id])
  stream_from "private_#{params[:resource_id]}"
end
```

### 2. Persist First, Broadcast Second

```ruby
# WRONG - Data lost if client offline
def speak(data)
  ActionCable.server.broadcast("chat", message: data['text'])
end

# RIGHT - Persist then broadcast
def speak(data)
  message = Message.create!(user: current_user, text: data['text'])
  ActionCable.server.broadcast("chat", message: message)
end
```

### 3. Use stream_for for Models

```ruby
# WRONG - Manual naming (error-prone)
stream_from "posts:#{params[:id]}"
ActionCable.server.broadcast("posts:#{@post.id}", data)

# RIGHT - Type-safe model broadcasting
stream_for @post
PostChannel.broadcast_to(@post, data)
```

---

## NEVER Do This

**NEVER** skip authorization:
```ruby
# Every channel MUST have: reject unless current_user
# Plus resource-specific authorization
```

**NEVER** broadcast before commit:
```ruby
# WRONG
post.save
ActionCable.server.broadcast(...)  # Transaction may rollback!

# RIGHT - Use after_commit callback
after_create_commit { broadcast_creation }
```

**NEVER** broadcast full objects:
```ruby
# WRONG - Leaks data, slow
ActionCable.server.broadcast("posts", post: @post)

# RIGHT - Only needed fields
ActionCable.server.broadcast("posts", post: @post.as_json(only: [:id, :title]))
```

**NEVER** create subscriptions without cleanup (JavaScript):
```javascript
// WRONG - Memory leak
consumer.subscriptions.create("ChatChannel", { ... })

// RIGHT - Cleanup on unmount
useEffect(() => {
  const sub = consumer.subscriptions.create(...)
  return () => sub.unsubscribe()
}, [])
```

---

## Channel Template

```ruby
class NotificationsChannel < ApplicationCable::Channel
  def subscribed
    # 1. Authorization (REQUIRED)
    reject unless current_user

    # 2. Subscribe to stream
    stream_from "notifications_#{current_user.id}"
  end

  def unsubscribed
    # Cleanup (optional)
  end

  # Client action: channel.perform('mark_as_read', {id: 123})
  def mark_as_read(data)
    notification = current_user.notifications.find(data['id'])
    notification.mark_as_read!

    ActionCable.server.broadcast(
      "notifications_#{current_user.id}",
      action: 'count_updated',
      unread_count: current_user.notifications.unread.count
    )
  end
end
```

---

## Stream Patterns Quick Reference

| Pattern | Use Case | Code |
|---------|----------|------|
| Personal | Notifications | `stream_from "user_#{current_user.id}"` |
| Model | Live updates | `stream_for @post` → `PostChannel.broadcast_to(@post, data)` |
| Group | Chat rooms | `stream_from "room_#{room.id}"` |
| Presence | Who's online | `stream_from "presence_#{room.id}"` + Redis |

---

## Broadcasting Patterns

### From Model (Recommended)
```ruby
class Post < ApplicationRecord
  after_create_commit { broadcast_creation }
  after_update_commit { broadcast_update }

  private

  def broadcast_creation
    PostChannel.broadcast_to(self, action: 'created', post: as_json(only: [:id, :title]))
  end
end
```

### From Controller
```ruby
def create
  @comment = @post.comments.create!(comment_params)
  CommentsChannel.broadcast_to(@post, action: 'created', comment: @comment.as_json)
end
```

### From Background Job
```ruby
class BroadcastJob < ApplicationJob
  def perform(channel_name, data)
    ActionCable.server.broadcast(channel_name, data)
  end
end
```

---

## Connection Authentication

```ruby
# 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 auth (default Rails)
      if user = User.find_by(id: cookies.encrypted[:user_id])
        user
      # Token auth (API clients)
      elsif user = find_user_from_token
        user
      else
        reject_unauthorized_connection
      end
    end

    def find_user_from_token
      token = request.params[:token]
      return nil unless token
      payload = JWT.decode(token, Rails.application.secret_key_base).first
      User.find_by(id: payload['user_id'])
    rescue JWT::DecodeError
      nil
    end
  end
end
```

---

## Testing Quick Reference

```ruby
# spec/channels/notifications_channel_spec.rb
RSpec.describe NotificationsChannel, type: :channel do
  let(:user) { create(:user) }

  before { stub_connection(current_user: user) }

  it 'subscribes to user 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

  it 'broadcasts on action' do
    subscribe
    expect {
      perform :mark_as_read, id: notification.id
    }.to have_broadcasted_to("notifications_#{user.id}")
  end
end
```

---

## Production Config

```yaml
# config/cable.yml
production:
  adapter: redis
  url: <%= ENV['REDIS_URL'] %>
  channel_prefix: myapp_production
```

```ruby
# config/environments/production.rb
config.action_cable.url = ENV['ACTION_CABLE_URL']
config.action_cable.allowed_request_origins = ['https://example.com']
```

---

## References

Detailed examples in `references/`:
- `javascript-consumers.md` - Client-side subscription patterns
- `presence-tracking.md` - Complete presence implementation with Redis
- `deployment.md` - Nginx, scaling, production configuration

Overview

This skill provides practical patterns for building real-time WebSocket features with Action Cable in Rails. It focuses on secure subscriptions, reliable broadcasting, and maintainable stream patterns for notifications, chat, presence, and live model updates. The guidance is opinionated and aimed at production-ready Rails apps.

How this skill works

The skill inspects channel and connection design to enforce authorization-first subscriptions and recommends broadcasting only after persistence or commit. It favors stream_for for model-scoped streams, personal/group/presence stream naming, and demonstrates where to place broadcasts (model callbacks, controllers, or background jobs). It also covers connection authentication, testing patterns, and production configuration for Redis-backed Action Cable.

When to use it

  • Building real-time chat rooms with group streams and message persistence
  • Sending personal notifications and unread counts to authenticated users
  • Broadcasting model updates for live editing or dashboards
  • Tracking presence (who’s online) across rooms using Redis
  • Securing WebSocket access and authorizing per-resource subscriptions

Best practices

  • Authorize in subscribed — always reject unless current_user and resource access are verified
  • Persist data first, then broadcast (use after_commit to avoid race conditions)
  • Prefer stream_for and broadcast_to for type-safe, model-scoped channels
  • Broadcast minimal payloads (only required fields) to avoid leaks and performance hits
  • Unsubscribe on client unmount to prevent memory leaks in single-page apps
  • Use Redis adapter and configure allowed origins in production

Example use cases

  • NotificationsChannel streaming to notifications_{user.id} with mark_as_read actions
  • ChatChannel streaming to room_{room.id} with persisted Message.create! then broadcast
  • PostChannel using stream_for @post and after_commit callbacks to broadcast updates
  • Presence tracking via presence_room_{room.id} backed by Redis to show online users
  • Broadcasting from background jobs for long-running workflows or external events

FAQ

How should I authenticate WebSocket connections?

Authenticate in ApplicationCable::Connection using cookie or token methods; set identified_by :current_user and call reject_unauthorized_connection when verification fails.

Where should I broadcast — model, controller, or job?

Prefer model callbacks (after_commit) for consistency; controllers are fine for request-triggered events; use background jobs for heavy or async broadcasts.