/ Tags: RAILS / Categories: RAILS

Action Cable — Real-Time Features in Rails with WebSockets

Turbo Streams handle real-time updates elegantly for most cases — server broadcasts a change, the page updates. But Turbo Streams build on top of Action Cable, and sometimes you need to work at that layer directly: two-way communication, presence indicators, chat, live cursors, anything where the browser needs to both receive and send data over a persistent connection. Action Cable is Rails’s built-in WebSocket framework, and it’s more capable than its occasional bad reputation suggests.

How Action Cable Is Structured


Action Cable wraps WebSocket connections with a channel abstraction. A channel is a Ruby class (similar to a controller) that handles communication over the WebSocket. Clients subscribe to channels and can send and receive messages through them.

Three moving parts:

  • Server-side channel — a Ruby class in app/channels/ that handles connection logic and incoming messages
  • Client-side subscription — a JavaScript object that connects to the channel and handles incoming messages
  • Broadcasting — server-side calls that push messages to all subscribers of a channel (or a specific stream within it)

A Practical Example: Live Notifications


Setup:

bin/rails generate channel Notifications

This creates app/channels/notifications_channel.rb and app/javascript/channels/notifications_channel.js.

Example:

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

  def unsubscribed
    stop_all_streams
  end
end

stream_for current_user creates a named stream unique to each user. When the server broadcasts to that user’s stream, only that user’s WebSocket connection receives it.

The client subscription:

Example:

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

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

  received(data) {
    const container = document.getElementById("notifications")
    container.insertAdjacentHTML("afterbegin", data.html)
  }
})

Broadcasting from anywhere in your Rails app:

Example:

# From a background job, model callback, or service
NotificationsChannel.broadcast_to(
  user,
  html: ApplicationController.renderer.render(
    partial: "notifications/notification",
    locals: { notification: notification }
  )
)

When a notification is created, the background job broadcasts the rendered HTML directly into the subscriber’s page. No polling, no page refresh.

Connection Authentication


Action Cable connections authenticate when the WebSocket is established — once, not per message. The connection class handles this:

Example:

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

identified_by :current_user makes current_user available in all channel classes. Authentication happens once at connection time; reject_unauthorized_connection closes the WebSocket for unauthenticated requests.

Two-Way Communication


Channels can receive messages from the client too — not just broadcast to them:

Example:

# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  def subscribed
    room = ChatRoom.find(params[:room_id])
    stream_for room
  end

  def send_message(data)
    room = ChatRoom.find(params[:room_id])
    message = room.messages.create!(
      content: data["content"],
      user: current_user
    )
    ChatChannel.broadcast_to(room, message: MessageSerializer.new(message).as_json)
  end
end

Example:

// Client sends a message
const subscription = consumer.subscriptions.create(
  { channel: "ChatChannel", room_id: roomId },
  {
    received(data) {
      renderMessage(data.message)
    },

    sendMessage(content) {
      this.perform("send_message", { content })
    }
  }
)

// Triggered by form submit
subscription.sendMessage("Hello from the browser")

this.perform("send_message", data) calls the send_message method on the server channel class. The server creates the record, then broadcasts to all subscribers in that room — including the sender, so everyone sees the message immediately.

Production Configuration


Action Cable requires a subscription adapter for multi-process/multi-server deployments. The default in-memory adapter only works for single-process development.

Setup:

# config/cable.yml
production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: myapp_production

For apps already using Solid Queue or wanting to avoid Redis entirely, Rails 8 adds a database adapter:

Setup:

production:
  adapter: solid_cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

solid_cable uses your existing database for pub/sub. Lower throughput ceiling than Redis, but eliminates another infrastructure dependency for most workloads.

Pro-Tip: Don’t put heavy business logic in channel methods. Action Cable connections are long-lived and their threads are shared resources. Channel methods that block — slow database queries, external API calls — degrade performance for all connected users. Offload work to background jobs and broadcast from there. The channel method should do minimal work: validate, enqueue, return.

Presence and Connected Users


Tracking who’s online is a common requirement. Action Cable’s subscribed/unsubscribed hooks make it straightforward:

Example:

class PresenceChannel < ApplicationCable::Channel
  def subscribed
    stream_from "presence"
    Redis.current.sadd("online_users", current_user.id)
    broadcast_presence
  end

  def unsubscribed
    Redis.current.srem("online_users", current_user.id)
    broadcast_presence
  end

  private

  def broadcast_presence
    online_ids = Redis.current.smembers("online_users").map(&:to_i)
    ActionCable.server.broadcast("presence", { online_user_ids: online_ids })
  end
end

When users connect and disconnect, all subscribers receive an updated list of online user IDs. Simple, real-time, no polling.

Conclusion


Action Cable is a mature WebSocket framework that integrates cleanly with the Rails auth model, job infrastructure, and now even the database adapter stack. For most real-time features — notifications, live updates, chat, presence — it covers the full requirement without reaching for a separate service. The key discipline is keeping channel methods lightweight and offloading real work to background jobs. Get that right and Action Cable handles the connection management, authentication, and broadcasting reliably in production.

FAQs


Q1: Can I use Action Cable with Turbo Streams simultaneously?
Yes — Turbo Streams are built on top of Action Cable. When you use Turbo::StreamsChannel, you’re using Action Cable with Turbo’s broadcasting helpers. Direct Action Cable usage is for cases where you need more control over the channel structure or two-way communication.

Q2: How many concurrent WebSocket connections can Rails handle?
It depends on your server and memory. Puma with Action Cable can handle thousands of concurrent WebSocket connections per process. The async adapter works for development; Redis or Solid Cable for multi-process production. Benchmark your specific workload — WebSocket connections are far less resource-intensive than HTTP requests.

Q3: Does Action Cable work with API-only Rails apps?
Not by default. API-only mode (rails new --api) strips Action Cable. You can re-enable it by adding the necessary middleware and mounting the cable endpoint. Alternatively, consider a dedicated WebSocket service if your API serves multiple client types.

Q4: How do I test Action Cable channels?
Rails provides ActionCable::Channel::TestCase for channel unit tests and have_broadcasted_to matcher for RSpec. Test that subscribed creates the right streams, that received messages trigger the expected actions, and that broadcasts contain the expected data.

Q5: What’s the difference between stream_from and stream_for?
stream_from accepts a string stream name: stream_from "chat_room_#{room.id}". stream_for accepts an Active Record object and generates the stream name automatically: stream_for room. Both work; stream_for is more idiomatic for model-based streams and ensures consistent naming.

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