Rails 8 Action Cable Without the Pain

WebSockets enable real-time bidirectional communication between browsers and servers. Rails 8 includes Action Cable, a framework for handling WebSocket connections that integrates seamlessly with the rest of the stack. While the real-time notifications article covered broadcasting patterns, this guide focuses on the foundational setup, channel architecture, and client-side patterns that make Action Cable work reliably.

Understanding the Action Cable Architecture

Action Cable operates on three core concepts: connections, channels, and subscriptions. A connection represents the WebSocket link between a client and server. Channels are logical units that handle specific features (like chat or notifications). Subscriptions are the client-side objects that listen to channels.

The connection authenticator runs once when the WebSocket handshakes. This is where user identification happens:

# 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
      if verified_user = User.find_by(id: cookies.encrypted[:user_id])
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

The identified_by declaration creates a connection identifier. This identifier persists for the connection lifetime and becomes available in all channel instances. Using encrypted cookies ensures the WebSocket inherits the same authentication as HTTP requests.

Building a Presence Channel

Presence tracking shows which users are currently online. This pattern demonstrates channel lifecycle callbacks and broadcasting to specific streams:

# app/channels/presence_channel.rb
class PresenceChannel < ApplicationCable::Channel
  def subscribed
    stream_from "presence_channel"
    
    current_user.update!(online_at: Time.current)
    broadcast_presence_update
  end

  def unsubscribed
    current_user.update!(online_at: nil)
    broadcast_presence_update
  end

  def ping
    current_user.touch(:online_at)
  end

  private

  def broadcast_presence_update
    online_users = User.where("online_at > ?", 5.minutes.ago)
                       .pluck(:id, :name)
                       .map { |id, name| { id: id, name: name } }

    ActionCable.server.broadcast(
      "presence_channel",
      { type: "presence_update", users: online_users }
    )
  end
end

The subscribed method fires when a client subscribes. The unsubscribed method fires on disconnection—whether the user closes the tab, loses internet, or explicitly unsubscribes. The ping method is a custom action clients can call to update their activity timestamp.

Stimulus Controller for Cable Connections

Managing WebSocket subscriptions in Stimulus requires careful lifecycle handling. Subscriptions should connect when the controller mounts and disconnect when it unmounts:

// app/javascript/controllers/presence_controller.js
import { Controller } from "@hotwired/stimulus"
import { createConsumer } from "@rails/actioncable"

export default class extends Controller {
  static targets = ["userList"]
  
  connect() {
    this.consumer = createConsumer()
    this.subscription = this.consumer.subscriptions.create(
      { channel: "PresenceChannel" },
      {
        connected: () => this.startPinging(),
        disconnected: () => this.stopPinging(),
        received: (data) => this.handleMessage(data)
      }
    )
  }

  disconnect() {
    this.stopPinging()
    if (this.subscription) {
      this.subscription.unsubscribe()
    }
    if (this.consumer) {
      this.consumer.disconnect()
    }
  }

  startPinging() {
    this.pingInterval = setInterval(() => {
      this.subscription.perform("ping")
    }, 30000)
  }

  stopPinging() {
    if (this.pingInterval) {
      clearInterval(this.pingInterval)
    }
  }

  handleMessage(data) {
    if (data.type === "presence_update") {
      this.renderUsers(data.users)
    }
  }

  renderUsers(users) {
    this.userListTarget.innerHTML = users
      .map(user => `
  • ${user.name}
  • `) .join("") } }

    The perform method calls server-side channel actions. Calling this.subscription.perform("ping") invokes the ping method defined in PresenceChannel. This bidirectional communication distinguishes Action Cable from simple server-sent events.

    Scoped Streams with Parameters

    Channels often need parameters to determine which stream to subscribe to. A document collaboration feature demonstrates this pattern:

    # app/channels/document_channel.rb
    class DocumentChannel < ApplicationCable::Channel
      def subscribed
        @document = Document.find(params[:document_id])
        
        unless @document.accessible_by?(current_user)
          reject
          return
        end
    
        stream_for @document
      end
    
      def update_cursor(data)
        broadcast_to(
          @document,
          type: "cursor_moved",
          user_id: current_user.id,
          user_name: current_user.name,
          position: data["position"]
        )
      end
    end

    The stream_for method creates a stream name from the model, generating something like document:Z2lkOi8v.... This approach uses GlobalID under the hood, ensuring unique stream names without manual string construction. The broadcast_to class method sends messages to that same stream.

    Client-side subscription with parameters:

    // app/javascript/controllers/document_controller.js
    import { Controller } from "@hotwired/stimulus"
    import { createConsumer } from "@rails/actioncable"
    
    export default class extends Controller {
      static values = { documentId: Number }
      
      connect() {
        this.consumer = createConsumer()
        this.subscription = this.consumer.subscriptions.create(
          { 
            channel: "DocumentChannel", 
            document_id: this.documentIdValue 
          },
          {
            received: (data) => this.handleMessage(data)
          }
        )
      }
    
      updateCursor(event) {
        this.subscription.perform("update_cursor", {
          position: { x: event.clientX, y: event.clientY }
        })
      }
    
      handleMessage(data) {
        if (data.type === "cursor_moved") {
          this.renderRemoteCursor(data)
        }
      }
    
      renderRemoteCursor(data) {
        let cursor = document.querySelector(`[data-cursor-user="${data.user_id}"]`)
        if (!cursor) {
          cursor = document.createElement("div")
          cursor.className = "remote-cursor"
          cursor.dataset.cursorUser = data.user_id
          cursor.innerHTML = `${data.user_name}`
          document.body.appendChild(cursor)
        }
        cursor.style.transform = `translate(${data.position.x}px, ${data.position.y}px)`
      }
    
      disconnect() {
        this.subscription?.unsubscribe()
        this.consumer?.disconnect()
      }
    }

    Production Configuration for MySQL

    Action Cable needs a pub/sub adapter to broadcast across multiple server processes. While Redis is common, Solid Cable provides a MySQL-backed alternative that reduces infrastructure dependencies:

    # config/cable.yml
    production:
      adapter: solid_cable
      connects_to:
        database:
          writing: cable
      polling_interval: 0.1.seconds
      message_retention: 1.day

    Run the Solid Cable installer to generate the necessary migrations and database configuration. This approach keeps WebSocket message routing within the existing MySQL infrastructure rather than adding Redis as another service to manage.

    Common Mistakes to Avoid

    Several patterns cause problems in production Action Cable deployments. Avoid broadcasting large payloads—send IDs and let clients fetch details via Turbo Frames. Never trust client-sent data without server-side validation; always verify the current user has permission for the requested action. Clean up subscriptions in Stimulus disconnect() methods to prevent memory leaks during Turbo navigation.

    Connection limits matter at scale. Each WebSocket holds a persistent connection, consuming server resources. Configure connection pools appropriately and consider implementing heartbeat timeouts to clean up stale connections.

    Summary

    Action Cable provides WebSocket infrastructure without external dependencies when combined with Solid Cable. The connection layer handles authentication once, channels encapsulate feature logic, and Stimulus controllers manage client-side subscriptions. Use stream_for with models for scoped broadcasts, implement proper cleanup in controller disconnect methods, and validate all client actions server-side. With these patterns, real-time features integrate naturally into Rails applications.

    10 claps
    ← Back to Blog