Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Scaling ActionCable to 10k Active WebSockets with Redis Pub/Sub

WebSockets look like the same stack as HTTP and are, in every way that matters, the opposite. An HTTP request occupies a thread for a hundred milliseconds and leaves. A WebSocket occupies a thread — and a file descriptor — for hours. This is the runbook for 10,000 concurrent connections: where the limits actually are, what to tune in Puma, Nginx, and Redis, and where Ruby’s threading model stops being the answer.

Count threads, not connections

The first thing to internalize: in the default ActionCable stack, every live WebSocket holds a Puma thread for its entire lifetime. The hijacked socket runs in the worker thread that accepted it and doesn’t give that thread back until the socket closes. Puma’s default thread count is 5, so default Puma handles five concurrent WebSockets per worker, no matter how idle the sockets are.

So 10,000 connections means 10,000 held threads, each reserving stack memory and a connection object. On a 20-process deployment that’s 500 threads per process, which is where you should set threads — and once threads are that high, the HTTP requests sharing those workers are the ones that starve. Isolate cable from HTTP entirely:

1
2
3
# config/puma.rb
workers 20
threads 500, 500   # cable workers, nothing else

Run the cable endpoint from a dedicated process group — puma -C config/puma/cable.rb mounted at /cable — so long-lived sockets never compete with request latency. Our rule of thumb: 500-800 connections per cable worker is comfortable.

File descriptors are the wall

Each socket is a file descriptor on the app server and another on the proxy. The default ulimit -n of 1024 caps you at roughly a thousand connections before “Can’t accept” errors, and nothing alerts until sockets get refused. On app hosts and the proxy, bump it:

1
ulimit -n 65536

and set LimitNOFILE=65536 in systemd. Also check net.core.somaxconn on the proxy and the ephemeral port range for outbound Redis. This is the boring part of scaling sockets and where most “unexplained” disconnect storms start.

Redis pub/sub, and how it scales badly

The cable.yml part is almost anticlimactic:

1
2
3
4
production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: randomcommits_production

The scaling problem hides in what the adapter does. When a message is broadcast, the server publishes it on a Redis channel; every worker with a local connection subscribed to that channel receives it and pushes it down the socket. Subscriptions are per-worker, per-channel. Ten thousand clients each on their own user channel across 40 workers means up to 400,000 Redis subscriptions, and that fan-out is the real ceiling, not the sockets.

Two Redis facts decide whether that fan-out survives:

  • Pub/sub is fire-and-forget. A worker whose Redis connection drops misses everything published while it was down, and so do its clients. If delivery must be guaranteed, pub/sub is the wrong tool; Redis Streams are the guaranteed path.
  • Slow subscribers get disconnected. A worker that can’t keep up with a hot channel makes Redis buffer the backlog, and client-output-buffer-limit decides who pays. The default is 32 MB; a busy channel plus a lagging worker hits it, Redis drops the subscriber, and every client on that worker disconnects in a wave. Set the limit deliberately:
1
config set client-output-buffer-limit "pubsub 32mb 8mb 60"

The reconnect storm is the compounding factor: 10,000 clients reconnect at once, each grabbing a thread, and one disconnected worker becomes a 5-minute partial outage. Backpressure means batching broadcasts and accepting that you don’t ship millions of messages a second over per-user channels.### The proxy layer

Nginx needs the upgrade handshake and timeouts measured in the life of a socket, not a request:

1
2
3
4
5
6
7
8
9
location /cable {
  proxy_pass http://cable_backend;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_read_timeout 3600s;
  proxy_send_timeout 3600s;
  proxy_buffering off;
}

proxy_read_timeout defaulting to 60 s is the classic idle-killer: a user reading a long page has a socket idle for more than a minute, the proxy drops it, and the client silently reconnects — each reconnect a fresh thread and a fresh Redis subscription. ActionCable’s built-in ping every few seconds means an honest 3600 s timeout is pure safety margin. Your load balancer also has to pass the Upgrade and Connection headers; the LBs that mangle them are a whole category of “works locally, not in production.”

Authentication

Authenticate in the connection, not the subscription. Rejecting unauthenticated connects means channels can rely on current_user without trusting the client:

1
2
3
4
5
6
7
class ApplicationCable::Connection < ActionCable::Connection::Base
  identified_by :current_user

  def connect
    self.current_user = authenticate_from_cookie || reject_unauthorized_connection
  end
end

When Ruby threads stop being the answer

The math above — 10k sockets, 10k+ threads, 400k Redis subscriptions — is where I reach for a different reactor. AnyCable, the Go-based ActionCable-compatible server, runs 100k+ sockets per process on a handful of goroutines and talks to Rails over gRPC, moving the fan-out problem out of Ruby entirely. If your ceiling is a few thousand sockets, the default stack with dedicated cable workers is fine. If the roadmap says tens of thousands, budget for the Go server before you spend a quarter tuning Redis buffer limits.

Production lessons

  • Connections equal threads in the default stack; size threads × workers to peak sockets and isolate cable from HTTP.
  • Raise file descriptor limits on every hop before you tune anything else.
  • Set proxy_read_timeout to 3600 s and disable buffering, or idle sockets die on a timer and reconnect in a herd.
  • Redis pub/sub drops messages under load: set client-output-buffer-limit, batch broadcasts, and use Streams if delivery is a requirement.
  • Prefix channels per environment and watch pubsub numsub — subscription count predicts Redis memory before the buffers do.

comments powered by Disqus