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:
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
endOn the client side, using the Rails WebSocket client library, you'd connect and listen:
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:
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
endThe 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:
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
endAlways 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:
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
endThe 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:
development:
adapter: inline
logger: &default_logger
<<: *default_logger
production:
adapter: redis
url: <%= ENV.fetch('REDIS_URL') %>
logger: &default_logger
<<: *default_loggerRedis 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:
production:
adapter: solid_cable
logger: &default_logger
<<: *default_loggerSolid 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. Useafter_create_commitcallbacks 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_forties 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.






Comments
Loading comments…