ActionCable Internals and Scaling

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.

What Action Cable actually is

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 Redis adapter is the part that scales

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.

Channel auth, the part you write

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 outside the channel

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.

When AnyCable becomes worth it

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.

Takeaways

  • Action Cable shares Puma’s process and GVL by default. Every WebSocket is a thread until you switch adapters.
  • Redis pub/sub is the fan-out layer. Async adapter is dev-only, and forgetting that is the most common Action Cable bug I’ve seen.
  • Channel receive should enqueue work, not run it. The channel is the doorway, Sidekiq is the room.
  • Broadcasts are fire-and-forget. Postgres is your source of truth. Cable is the nudge.
  • AnyCable is worth it when Puma memory says so, not when Twitter says so.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.