/ Tags: RAILS / Categories: RAILS

Action Cable Channels and Broadcasting — A Practical Setup Guide

Most Rails apps start request-response and stay that way. Then a product requirement lands — live notifications, a chat feature, a dashboard that updates without refreshing — and suddenly you’re looking at WebSockets. Action Cable ships with Rails and handles the hard parts: connection management, pub/sub, thread safety. You don’t need a separate service or a new framework. You just need to understand how the pieces fit together.

How Action Cable Works


Action Cable sits on top of WebSockets and gives you a structured abstraction: connections, channels, and streams.

  • Connection — one persistent WebSocket connection per browser tab. Lives in app/channels/application_cable/connection.rb. This is where you authenticate the user.
  • Channel — a logical grouping, like a controller. One channel per feature area (NotificationsChannel, ChatChannel). Lives in app/channels/.
  • Stream — a named pub/sub topic a channel subscribes to. When you broadcast to a stream name, every subscriber receives it.
  • Consumer — the client side. A JavaScript object (created by Action Cable’s client library) that connects to the server and subscribes to channels.
  • Subscription — the client’s membership in a channel. Each subscription maps to a channel instance on the server.

The flow is: consumer connects → subscribes to a channel → channel subscribes to a stream → your app broadcasts to that stream → consumer’s received callback fires.

Server-Side: Setting Up a Channel


Generate a channel with the Rails generator:

Setup:

rails generate channel Notifications

This creates app/channels/notifications_channel.rb and a corresponding JavaScript file. The channel class:

Example:

# app/channels/notifications_channel.rb
class NotificationsChannel < ApplicationCable::Channel
  def subscribed
    stream_for current_user
  end

  def unsubscribed
    # Clean up resources if needed
    stop_all_streams
  end
end

stream_for is the high-level method — it generates a stream name from the model and the channel name automatically (notifications:Z2lk...). The alternative, stream_from, takes an explicit string:

Example:

def subscribed
  stream_from "notifications_#{current_user.id}"
end

Both work. stream_for is preferred when you have a model to key the stream on — it handles the naming consistently and pairs naturally with broadcast_to. stream_from is useful when the stream name isn’t tied to a specific record.

Authentication in the Connection


The connection is where you verify who’s connecting. It runs once when the WebSocket handshake happens, not on every message. Set it up in app/channels/application_cable/connection.rb:

Example:

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Server::Base::Connection
    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

identified_by :current_user tells Action Cable to track connections by user. If find_verified_user calls reject_unauthorized_connection, the WebSocket handshake is refused and the connection never opens. Once authenticated, current_user is available in every channel method on this connection.

If you’re using Devise (or any session-based auth), the session cookie is accessible during the WebSocket handshake because it’s part of the HTTP upgrade request. The cookies.encrypted[:user_id] pattern above mirrors what session-based auth stores.

Broadcasting Messages


There are two ways to broadcast, and they correspond to the two stream setup methods:

broadcast_to — model-scoped

Example:

# Broadcast a notification to a specific user
NotificationsChannel.broadcast_to(
  current_user,
  { type: "mention", message: "You were mentioned in a comment", url: "/posts/42" }
)

broadcast_to serializes the model reference to the same stream name that stream_for subscribes to. Call this from anywhere: a background job, an Active Record callback, a service object.

ActionCable.server.broadcast — explicit stream name

Example:

# Broadcast to an explicit stream name
ActionCable.server.broadcast(
  "notifications_#{user.id}",
  { type: "alert", message: "Your export is ready" }
)

Use this when you set up the subscription with stream_from and a custom string. Both approaches produce identical WebSocket frames on the wire — they’re just convenience wrappers around the same underlying pub/sub mechanism.

A common pattern is broadcasting from an Active Job after a slow operation completes:

Example:

# app/jobs/export_complete_job.rb
class ExportCompleteJob < ApplicationJob
  def perform(user_id, export_url)
    user = User.find(user_id)
    NotificationsChannel.broadcast_to(
      user,
      { type: "export_ready", url: export_url }
    )
  end
end

Client-Side JavaScript Subscription


Action Cable ships with a JavaScript client. In a standard Rails app with import maps or a bundler, the consumer is already set up. Subscribing to a channel looks like this:

Example:

// app/javascript/channels/notifications_channel.js
import consumer from "./consumer"

consumer.subscriptions.create("NotificationsChannel", {
  connected() {
    console.log("Connected to NotificationsChannel")
  },

  disconnected() {
    console.log("Disconnected")
  },

  received(data) {
    // data is the Ruby hash you broadcast, parsed from JSON
    if (data.type === "mention") {
      showNotificationBadge(data.message)
    } else if (data.type === "export_ready") {
      enableDownloadButton(data.url)
    }
  }
})

The consumer.js file (auto-generated) creates the WebSocket connection:

Example:

// app/javascript/channels/consumer.js
import { createConsumer } from "@rails/actioncable"
export default createConsumer()

createConsumer() with no argument connects to /cable, the default Action Cable mount point in config/routes.rb. The connection is shared across all subscriptions in the same tab — one WebSocket, multiple logical channels.

When to Use Action Cable vs Alternatives


Not every real-time requirement needs a WebSocket. Picking the right tool matters:

HTTP Polling

Simple to implement, works everywhere, no persistent connection. The right choice for low-frequency updates (every 30–60 seconds) where a slight delay is acceptable. Kills your server under load if the interval is too short.

Server-Sent Events (SSE)

Unidirectional — server pushes to client, client can’t send back over the same connection. Built into browsers via EventSource. Good for live feeds, dashboards, log tailing. Rails supports SSE via ActionController::Live. Lower overhead than WebSockets when bidirectionality isn’t needed.

Action Cable (WebSockets)

Bidirectional, full-duplex. The right choice when the client needs to send data back — chat, collaborative editing, interactive games, presence indicators. Also necessary when you need to push non-HTML payloads (JSON events, binary data).

Pro-Tip: If your app uses Hotwire (Turbo), you probably don’t need raw Action Cable for most cases. turbo_stream_from wraps Action Cable under the hood and handles DOM updates automatically — your server broadcasts Turbo Stream HTML fragments and Turbo applies them directly. Reach for Action Cable directly only when you need bidirectional communication, non-HTML payloads, or custom JavaScript logic on received messages. Start with turbo_stream_from; drop down to raw channels when you outgrow it.

Conclusion


Action Cable gives you a clean, Rails-idiomatic path to real-time features without standing up a separate WebSocket service or learning a new paradigm. The mental model is straightforward once you have it: authenticate in the connection, subscribe to streams in the channel, broadcast from anywhere in your app, handle received data in the JavaScript subscription. For most Hotwire apps, turbo_stream_from covers the common case with even less code. But when you need the full power of bidirectional WebSocket communication — Action Cable is already there, battle-tested, and ready to go.

FAQs


Q1: Does Action Cable work with Puma in production?
Yes, but you need to run Puma with multiple threads and the async adapter (the default) or switch to the Redis adapter for multi-process deployments. The Redis adapter is strongly recommended in production — it lets multiple Puma workers broadcast to the same subscribers correctly.

Q2: How do I pass parameters to a channel subscription?
Pass them as the second argument to subscriptions.create: consumer.subscriptions.create({ channel: "ChatChannel", room_id: 42 }, { received(data) { ... } }). On the server, access them via params[:room_id] inside the channel’s subscribed method.

Q3: How do I test Action Cable channels in RSpec?
Use the action_cable_matchers or the built-in Rails ActionCable::Channel::TestCase. For RSpec, stub_connection sets up the connection with a mock current user, and subscribe calls the subscribed method. Assert broadcasts with expect { broadcast }.to have_broadcasted_to(user) via the assert_broadcasts helper or ActionCable’s RSpec matchers.

Q4: Can I use Action Cable without Redis?
Yes. The default adapter is :async, which works in a single-process setup (like development with one Puma worker). For production with multiple processes or Heroku dynos, you need the Redis adapter — otherwise broadcasts from one process won’t reach subscribers connected to another process.

Q5: What happens if the WebSocket connection drops?
The Action Cable JavaScript client retries automatically with an exponential backoff. The disconnected() callback fires when the connection is lost; connected() fires again when it’s re-established. Subscriptions are automatically re-created on reconnect, so your subscribed method runs again and reattaches to the stream.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More