How Action Cable actually moves WebSocket frames through Puma, Redis, and your channels, and the point at which AnyCable starts paying for itself.
Tuesday, around 10 a.m. Pacific, at the creator economy platform I worked at. The Community squad was rolling out a “live reactions” stream on Action Cable. Staging looked clean. We flipped it on for a slice of creators in production and then watched Puma worker memory climb past 1.4 GB inside fifteen minutes. p99 broadcast latency from broadcast to client receive sat around 2.8 seconds. A page that was supposed to feel real-time felt like a stale chat log.
I’d seen this shape before, just not in Rails. Here’s what Action Cable actually does under the hood, where it falls apart, and the moment AnyCable starts paying for itself.
Three things glued together. A Rack-compatible WebSocket server (ActionCable::Server), a pub/sub adapter (Redis in prod, async in dev), and channels that look like Rails controllers but live for the duration of a socket.
Every WebSocket connection is owned by a Puma thread. Most teams miss this. A Rails HTTP request takes the thread for ~50 ms then gives it back. A WebSocket takes the thread for as long as the user has the tab open. Action Cable hot-swaps to an EventMachine-based reactor that multiplexes many sockets onto one thread, which is what saves you in practice. The default Rails 7 config mounts Action Cable in-process. Same Ruby VM, same GVL, same memory.
The default mount:
# config/routes.rb
Rails.application.routes.draw do
mount ActionCable.server => "/cable"
end
Connections share the GVL with your HTTP requests. A long-running channel action will block other channel actions on the same worker, exactly like a slow controller blocks other controllers.
The real work of fan-out lives in cable.yml. In production you want Redis. The async adapter is dev only. It pub/subs in-process, so a broadcast from worker A never reaches a subscriber on worker B.
# config/cable.yml
production:
adapter: redis
url: <%= ENV.fetch("REDIS_CABLE_URL") %>
channel_prefix: kajabi_community_production
ssl_params:
verify_mode: <%= OpenSSL::SSL::VERIFY_NONE %>
When you call ActionCable.server.broadcast("room:42", payload), Action Cable does a Redis PUBLISH. Every Puma worker with a subscriber on room:42 is on a SUBSCRIBE, and Redis fans the message back. Each worker then writes the frame to its local socket fd.
Bottleneck shows up in two places. Redis pub/sub throughput on a single instance (one node, single-threaded), and per-connection memory on Puma. At ~5K concurrent sockets per worker, with Ruby’s GC and the per-channel state, you’re at 1.2 to 2 GB resident. A box that ran ten workers for HTTP now runs three.
The channel API hides most of the wiring. What you write is the connection identifier, the channel subscription, and the broadcast.
# 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
logger.add_tags "ActionCable", "user:#{current_user.id}"
end
private
def find_verified_user
token = request.params[:token] || cookies.encrypted[:session_token]
user = AuthToken.verify(token)
reject_unauthorized_connection unless user
user
end
end
end
# app/channels/community_feed_channel.rb
class CommunityFeedChannel < ApplicationCable::Channel
def subscribed
return reject unless current_user.member_of?(params[:community_id])
stream_from "community:#{params[:community_id]}:feed"
end
def receive(data)
# never trust the client. validate, then enqueue, do not run business logic here
CommunityFeed::HandleClientEventJob.perform_later(
community_id: params[:community_id],
user_id: current_user.id,
payload: data.slice("type", "post_id", "reaction")
)
end
def unsubscribed
stop_all_streams
end
end
Two things doing load-bearing work. reject_unauthorized_connection runs before the socket upgrade completes, so unauthenticated clients never tie up a thread. And receive enqueues a Sidekiq job instead of running business logic inline. Do real work in receive and every chatty client becomes a synchronous Rails request on the same worker. Don’t.
Broadcasting from a controller, a callback, or a job is the common case. broadcast is fire-and-forget. No delivery ack from the client. If the worker owning the socket has crashed, Redis still happily accepts the publish.
# app/services/community_feed/publish_reaction.rb
class CommunityFeed::PublishReaction
def self.call(community_id:, post_id:, user_id:, reaction:)
payload = {
type: "reaction.added",
post_id: post_id,
user_id: user_id,
reaction: reaction,
at: Time.current.iso8601
}
ActionCable.server.broadcast(
"community:#{community_id}:feed",
payload
)
rescue Redis::BaseConnectionError => e
# cable is best-effort. the canonical write lives in postgres
Rails.logger.warn("cable_publish_failed", error: e.message, community_id: community_id)
end
end
Notice the rescue. Cable failures shouldn’t fail the request. Postgres is the source of truth. The broadcast is the optimistic UI nudge.
I’ve put off the migration on multiple Rails apps and I’ve also pulled the trigger. Honest answer: stay on Action Cable until your Puma workers are dying of connection memory. When workers start OOMing, GC time spikes, or you can’t scale Redis pub/sub horizontally anymore, AnyCable starts paying for itself.
AnyCable splits the WebSocket termination off Ruby. A Go process (anycable-go) holds the sockets. Your Rails app exposes a gRPC server that AnyCable calls into for channel actions, auth, and subscription decisions. Sockets stop touching Ruby. Memory drops by an order of magnitude for the same connection count.
# Gemfile
gem "anycable-rails", "~> 1.5"
# config/anycable.yml
production:
redis_url: <%= ENV.fetch("REDIS_CABLE_URL") %>
rpc_host: "0.0.0.0:50051"
log_level: info
broadcast_adapter: redisx
Migration cost is real. Channels that touch connection.transmit or rely on per-connection Ruby state need refactoring. Custom adapters get rewritten. A standard codebase moves in a week. One with custom subscription registries or complex auth handshakes, more like a month.
Migrate when your dashboards say you have to. Not before.
receive should enqueue work, not run it. The channel is the doorway, Sidekiq is the room.Thanks for reading. If you’ve got thoughts, send them my way.