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
endThe 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
endThe 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
endThe 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.dayRun 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.