Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 032 / 100

Action Cable: concepts, trade-offs and production lessons

WebSockets in Rails.

Expona Admin6 min read

Introduction

Real-time features have become table stakes in modern web applications—from live notifications to collaborative editing to live dashboards. Action Cable, Rails' built-in WebSocket framework, lets you push data to connected clients without polling, but production deployments reveal hard trade-offs between simplicity, performance, and operational complexity. In this article, we explore Action Cable: concepts, trade-offs and production lessons from the perspective of teams shipping real-time features at scale.

Understanding Channels for Real-Time Features

Action Cable organizes WebSocket connections around the concept of channels. Each channel is a named subscription that a client can join, and the server can broadcast messages to all connected subscribers of that channel. Think of a channel as a pub-sub topic: one or many clients subscribe, and when you broadcast a message, all subscribers receive it instantly.

Let's say you're building a collaborative document editor. You'd create a document channel that users join when they open a document:

ruby
class DocumentChannel < ApplicationCable::Channel
  def subscribed
    @document = Document.find(params[:id])
    stream_for @document
  end

  def update_content(data)
    @document.update!(content: data['content'])
    broadcast_to(@document, type: 'content_updated', content: data['content'])
  end
end

On the client side, using the Rails WebSocket client library, you'd connect and listen:

ruby
consumer.subscriptions.create(
  { channel: 'DocumentChannel', id: 42 },
  {
    received: function(data) {
      if (data.type === 'content_updated') {
        updateEditorContent(data.content);
      }
    }
  }
)

The magic is that stream_for @document automatically routes all broadcasts for that document to the right clients. If five users edit the same document, all five get the update instantly. This is far cleaner than polling or manually managing WebSocket connections.

In practice, channels become your application's real-time API. A notification channel, a chat channel, a live orders channel—each one encapsulates a specific broadcast topic. The model-based naming (stream_for @document) is powerful because it ties broadcasts to your domain objects, not arbitrary strings.

Authenticate Connections

WebSocket connections must be authenticated just as carefully as HTTP requests, yet it's easy to get wrong. Action Cable allows you to authenticate at two levels: the connection (establishing the WebSocket) and the channel subscription (joining a specific channel).

Connection-level authentication happens in ApplicationCable::Connection:

ruby
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
      logger.add_tags 'ActionCable', current_user.id
    end

    private

    def find_verified_user
      verified_user = User.find_by(id: cookies.encrypted['user_id'])
      verified_user || reject_unauthorized_connection!
    end
  end
end

The connection uses encrypted cookies to identify the user. If no valid user is found, reject_unauthorized_connection! closes the WebSocket. This prevents anonymous clients from connecting.

Then, at the channel level, you add authorization for specific resources:

ruby
class DocumentChannel < ApplicationCable::Channel
  def subscribed
    @document = Document.find(params[:id])
    
    if current_user.can_read?(@document)
      stream_for @document
    else
      reject_subscription
    end
  end
end

Always verify access to the resource before streaming. A user who cannot read a document should not receive broadcasts for it. This is especially critical in multi-tenant applications where data isolation is essential.

One common pitfall: relying only on connection-level authentication. If a user loses access to a resource (their team membership is revoked, a document is deleted), they remain subscribed and continue receiving broadcasts. Consider adding periodic permission checks or explicit unsubscribe logic when permissions change.

Turbo Streams Build on Action Cable

Turbo Streams is Rails' newer abstraction for sending DOM updates over WebSockets or HTTP. It works seamlessly with Action Cable and is often the easier choice for server-side-rendered Rails apps that want to add real-time updates without writing much JavaScript.

Instead of sending raw JSON and writing custom client logic, Turbo Streams let you send HTML fragments that the browser inserts, replaces, or removes automatically. Here's a simple example: when a new comment is posted, broadcast it to all viewers of that post:

ruby
class Comment < ApplicationRecord
  belongs_to :post

  after_create_commit :broadcast_append
  after_update_commit :broadcast_update
  after_destroy_commit :broadcast_remove

  private

  def broadcast_append
    broadcast_append_to("post_#{post_id}", 
      partial: 'comments/comment', 
      locals: { comment: self }, 
      target: 'comments'
    )
  end

  def broadcast_update
    broadcast_update_to("post_#{post_id}", 
      partial: 'comments/comment', 
      locals: { comment: self }
    )
  end

  def broadcast_remove
    broadcast_remove_to("post_#{post_id}", target: comment_dom_id(self))
  end
end

The view partial renders just the comment HTML, and Turbo appends it to the comments list on all connected clients. No custom JavaScript needed. The Rails magic handles targeting and DOM updates.

However, Turbo Streams is most natural for server-side rendering. If you're building a heavy client-side app with React or Vue, raw Action Cable channels with custom message handling often give you better control and smaller payloads. Consider your stack: traditional Rails + ERB templates? Turbo Streams shines. SPA with a Rails backend? Raw channels are often cleaner.

Scale with Solid Cable or Redis

By default, Action Cable stores subscriptions in memory on a single process. That works fine in development and small deployments, but as soon as you run multiple Rails processes or scale horizontally, you hit a critical issue: a client connected to process A cannot receive a broadcast sent by process B.

The solution is an adapter that syncs subscriptions across all processes. Historically, Redis was the only production choice:

ruby
development:
  adapter: inline
  logger: &default_logger
    <<: *default_logger

production:
  adapter: redis
  url: <%= ENV.fetch('REDIS_URL') %>
  logger: &default_logger
    <<: *default_logger

Redis works well: it's battle-tested, fast, and handles pub-sub natively. But it adds infrastructure—another service to deploy, monitor, and scale. If you're already running Redis for caching or jobs, the marginal cost is low. If not, it's an operational burden.

Enter Solid Cable (released in Rails 8), which uses PostgreSQL and a job queue (Solid Queue) instead of Redis. If you already depend on PostgreSQL and have a job system, Solid Cable eliminates the Redis requirement:

ruby
production:
  adapter: solid_cable
  logger: &default_logger
    <<: *default_logger

Solid Cable broadcasts messages into a PostgreSQL table and uses a background job worker to deliver them. It's simpler operationally if you're PostgreSQL-first, though slightly higher latency than Redis (messages queue through jobs). For most applications—notifications, live feeds, collaborative features—the latency difference is imperceptible.

Regardless of adapter, monitor subscription counts and broadcast throughput. If you have 50,000 concurrent WebSocket connections and broadcast to all of them, that's a sustained load event. Use background jobs to defer heavy processing; broadcast only essential data; prune old subscriptions if they leak. We've seen production incidents where a buggy broadcast loop saturated Redis or PostgreSQL connection pools.

Common Mistakes

  • Forgetting connection authentication. Leaving connections open to unauthenticated users is a security breach. Always verify identity in ApplicationCable::Connection.
  • Broadcasting sensitive data. Action Cable broadcasts are visible to all subscribers. Don't send passwords, tokens, or PII. Filter your payloads.
  • Ignoring channel-level authorization. Authenticating the connection is not enough. Always check resource access before streaming to a channel.
  • Broadcasting in request handlers. Calling broadcast_to() in a controller action blocks the request. Use after_create_commit callbacks or background jobs instead.
  • No subscription cleanup. If a user logs out or navigates away, ensure they unsubscribe. Zombie subscriptions consume memory and can leak data.
  • Relying on memory adapter in production. Using the default in-memory adapter with multiple processes means broadcasts don't reach all clients. Always use Redis, Solid Cable, or another shared adapter.
  • Broadcasting large payloads. WebSocket frames have limits. Sending 10 MB documents over channels causes dropped connections. Use deltas or references instead.

Key Takeaways

  • Channels organize real-time broadcasts around domain objects; stream_for ties subscriptions to models elegantly.
  • Always authenticate connections and authorize channel subscriptions; a valid user is not a valid subscriber to every resource.
  • Turbo Streams simplify real-time updates in traditional Rails apps; raw Action Cable channels give better control in client-side applications.
  • Scale subscription sync with Redis (proven, ubiquitous) or Solid Cable (simpler if PostgreSQL-first).
  • Broadcast asynchronously from callbacks, not request handlers; monitor broadcast throughput and subscription counts in production.
  • Filter sensitive data from broadcasts and prune zombie subscriptions to prevent data leaks and memory bloat.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles