LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 1 views

Ruby on Rails and WebSockets

Typing "ruby on rails websockets" into a search box returns a great deal of advice about installing something, and the first thing to know is that there is nothing to install and the server is already running. A Rails 8.1 app answers WebSocket upgrades on /cable out of the box. What it does not have is a single file in app/channels, which is why the directory looks missing and the endpoint looks absent, and those are two different facts that usually get collapsed into one wrong conclusion.

Everything below was run in a scratch application generated today with rails new on this laptop: rails 8.1.4, actioncable 8.1.4, solid_cable 4.1.0, puma 8.0.2, PostgreSQL 17.7 on port 15432, Ruby 4.0.5, macOS arm64-darwin25 on an Apple M2 Max with 12 cores. The WebSocket client used for the frame traces is 40 lines built on websocket-driver 0.8.2, which is the same gem actioncable itself depends on. Load numbers come from /usr/sbin/ab.

/cable is mounted and app/channels does not exist

A generated Rails 8.1 app has no app/channels directory. That is not an artifact of a skip flag: the app generator's own templates do not contain one. ls on railties-8.1.4/lib/rails/generators/rails/app/templates/app/ returns assets controllers helpers jobs mailers models views, and the only mention of the word channel anywhere in app_generator.rb is line 558, remove_dir "app/javascript/channels". The same is true of the railties 8.1.3.1 gem sitting next to it.

The server is mounted anyway, with the framework's own base classes standing in for the ones you have not written:

puts ActionCable.server.config.mount_path.inspect
puts ActionCable.server.config.connection_class.call.name
puts ActionCable.server.config.worker_pool_size
puts ActionCable.server.config.cable.inspect
"/cable"
ActionCable::Connection::Base
4
{"adapter" => "async"}

bin/rails generate channel Ticker is what creates app/channels/application_cable/connection.rb, channel.rb, the channel itself, and four files under app/javascript/channels. Until it runs, ApplicationCable::Connection is not a constant that exists.

The frames, in order, with timestamps

Reading an Action Cable session off the socket takes a client that speaks nothing but WebSocket, and what comes back is short enough to print whole. The script below connects, waits half a second, sends one subscribe command, and prints every frame the server sent with the time since it started. RawCable is a 40 line wrapper around WebSocket::Driver.client:

t0 = Time.now
c = RawCable.new("ws://localhost:3487/cable?token=tok-alice", origin: "http://localhost:3487")
c.pump(0.5)
c.send_json(command: "subscribe",
            identifier: JSON.generate(channel: "TickerChannel", room: "eurusd"))
c.pump(8)
c.frames.each { |(t, d)| printf("%6.3fs  %s\n", t - t0, d) }
 0.012s  {"type":"welcome"}
 0.509s  {"identifier":"{\"channel\":\"TickerChannel\",\"room\":\"eurusd\"}","message":{"hello":"alice","room":"eurusd"}}
 0.509s  {"identifier":"{\"channel\":\"TickerChannel\",\"room\":\"eurusd\"}","type":"confirm_subscription"}
 2.791s  {"type":"ping","message":1790540192}
 5.791s  {"type":"ping","message":1790540195}

Two things in that trace are worth more than the rest. The transmit call inside subscribed arrives before confirm_subscription, in the same millisecond but ahead of it in the stream. Any client that queues work until it sees the confirmation has already been handed a message it has not looked at.

And the pings are not optional traffic. BEAT_INTERVAL = 3 in actioncable-8.1.4/lib/action_cable/server/connections.rb runs one timer for the whole server that posts connections.each(&:beat) every 3 seconds. The JavaScript side sets ConnectionMonitor.staleThreshold = 6 in action_cable.js, so two missed beats and the browser tears the socket down and reconnects with backoff at reconnectionBackoffRate = .15. A proxy with a 60 second idle timeout in front of /cable is therefore never the thing that closes these connections, which is worth knowing before anyone spends an afternoon tuning one.

The identifier on every application frame is the JSON of the subscribe command, echoed back verbatim as a string, including the room parameter. That is how one socket carries several subscriptions.

Request origin not allowed: with nothing after the colon

The first attempt at the trace above failed, and it failed in a way that reads like a routing mistake:

 0.018s  CLOSE code=1002 reason="Error during WebSocket handshake: Unexpected response code: 404"

The server log:

Started GET "/cable?token=[FILTERED]" [WebSocket] for ::1 at 2026-09-27 22:16:02 +0200
Request origin not allowed: 
Failed to upgrade to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)

That empty space after the colon is the whole message. allow_request_origin? in action_cable/connection/base.rb compares env["HTTP_ORIGIN"] against config.allowed_request_origins, and nil matches nothing, so a client that sends no Origin header at all is refused. The refusal is respond_to_invalid_request, which returns [404, ..., ["Page not found"]]. Nothing about that response says origin, and the 404 sends people to config/routes.rb.

Browsers always send Origin, so this is invisible until something that is not a browser connects: a Ruby or Python script, a load generator, a mobile client, a health check. In development options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ is set in action_cable/engine.rb line 45, so a browser on localhost works and the script next to it does not. In production nothing is set and allow_same_origin_as_host defaults to true, which means the Origin must equal "#{proto}://#{env['HTTP_HOST']}" exactly, protocol and port included. Setting config.action_cable.disable_request_forgery_protection = true makes the 404 go away and turns the endpoint into one anybody's page can open a socket to, with whatever cookies the browser has. Adding the real origins to config.action_cable.allowed_request_origins is the answer; the dev-only comment already sitting at line 62 of config/environments/development.rb is not.

Action Cable authentication is one method, and rejection is a frame

identified_by plus a connect method is the entire authentication surface. This one reads a token off the query string, because the client in these traces is a script and has no cookies:

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

    def connect
      self.current_user = find_verified_user
      logger.add_tags "user:#{current_user}"
    end

    private
      def find_verified_user
        token = request.params[:token]
        token.presence && token.start_with?("tok-") ? token.delete_prefix("tok-") : reject_unauthorized_connection
      end
  end
end

A real application reads cookies.encrypted[:session_id] or request.session here instead. The shape does not change: set the identifier, or call reject_unauthorized_connection.

Rejection is not a failed handshake. The socket opens, and then:

 0.007s  {"type":"disconnect","reason":"unauthorized","reconnect":false}
 0.007s  CLOSE code=1000 reason=""
 0.008s  EOF (server closed the TCP socket)

reconnect: false is the part that matters, because it is what stops the JavaScript client from reconnecting in a loop against a session that will never be valid. Compare it with the origin failure above, which never reached WebSocket at all: one is a 404 on an HTTP request, the other is a clean close with a reason, and telling them apart in a browser console is most of the work of debugging a /cable that does not connect.

The adapter is the design, and the default one drops your broadcast

config/cable.yml in a generated app says adapter: async for development. That adapter is a queue inside one Ruby process. Here is a client connected and subscribed while a broadcast is sent from a separate process:

$ bin/rails runner 'p ActionCable.server.broadcast("ticker:eurusd", { price: 1.0842 })'
nil
 0.029s  {"type":"welcome"}
 0.512s  {"identifier":"{\"channel\":\"TickerChannel\",\"room\":\"eurusd\"}","message":{"hello":"alice","room":"eurusd"}}
 0.513s  {"identifier":"{\"channel\":\"TickerChannel\",\"room\":\"eurusd\"}","type":"confirm_subscription"}
 3.026s  {"type":"ping","message":1790541811}
 6.032s  {"type":"ping","message":1790541814}
 9.037s  {"type":"ping","message":1790541817}
12.042s  {"type":"ping","message":1790541820}

Fourteen seconds of listening, nothing but heartbeats. ActionCable.server.broadcast returned nil, printed no warning and logged nothing. The same broadcast issued from a controller action inside the running Puma arrived in 0.5 ms. This is the single most expensive thing about the default configuration: a broadcast from a Sidekiq worker, a Solid Queue job in its own process, a rake task or a console is discarded in silence in development, and the same code works in production because production uses solid_cable. The comment at the top of config/cable.yml says so in four lines that everybody scrolls past.

What Solid Cable charges for delivering the broadcast

Switching development to solid_cable fixes the dropped broadcast above and is not free. Three configurations, measured the same way: 30 broadcasts at 0.2 s intervals, each payload stamped with Time.now.to_f at broadcast, against the wall clock when the client finished reading the frame, broadcaster and client both on localhost.

config/cable.yml development block median delivery max
adapter: async, broadcast in the Puma process 0.5 ms 1.2 ms
solid_cable, polling_interval: 0.1.seconds 56.1 ms 117.8 ms
solid_cable, polling_interval: 0.01.seconds 11.8 ms 23.4 ms

Cross-process delivery on 0.1.seconds measured median 68.8 ms rather than 56.1 ms, with the broadcaster in its own bin/rails runner.

The cost of those milliseconds is a polling thread. It does not start at boot: @listener = nil in action_cable/subscription_adapter/solid_cable.rb and the private listener method memoizes it on the first subscribe, so a server nobody has connected to yet queries nothing. Once one subscription exists it polls forever. Measured with ps -o cputime= on the Puma process over 30 second windows, clients connected and subscribed but sending and receiving nothing:

state process CPU over 30 s
booted, nothing has ever subscribed 0.01 s
async, 1 idle subscriber 0.01 s
async, 500 idle subscribers 0.14 s
solid_cable at 0.1.seconds, 1 idle subscriber 0.56 s
solid_cable at 0.1.seconds, 500 idle subscribers 1.17 s

Just under two percent of a core, per process, to deliver nothing to one subscriber, is the price of the default polling interval, and four Puma workers make that most of a tenth of a core running SELECT against an empty table. The position here: 0.1.seconds is right for anything a human looks at, because 56 ms is under the threshold at which a page update reads as instant, and the only reason to lower it is a feature where two machines are racing, at which point the question is whether a polled table belongs in that path at all.

Every broadcast is an INSERT

Solid Cable is a table. Five broadcasts, then the rows:

select id, convert_from(channel,'UTF8') as channel, channel_hash,
       convert_from(payload,'UTF8') as payload, created_at
from solid_cable_messages order by id;
 id |    channel    |    channel_hash     |                 payload                  |         created_at
----+---------------+---------------------+------------------------------------------+----------------------------
 61 | ticker:eurusd | 4284805293611697416 | {"price":1.0842,"at":1790540953.56615}   | 2026-09-27 20:29:13.568437
 62 | ticker:eurusd | 4284805293611697416 | {"price":1.0842,"at":1790540953.767549}  | 2026-09-27 20:29:13.769316
 63 | ticker:eurusd | 4284805293611697416 | {"price":1.0842,"at":1790540953.968411}  | 2026-09-27 20:29:13.970138
 64 | ticker:eurusd | 4284805293611697416 | {"price":1.0842,"at":1790540954.1734378} | 2026-09-27 20:29:14.175253
 65 | ticker:eurusd | 4284805293611697416 | {"price":1.0842,"at":1790540954.3786628} | 2026-09-27 20:29:14.380391

channel and payload are bytea. The lookup is not on the channel name: it is on channel_hash, which SolidCable::Message.channel_hash_for computes as Digest::SHA256.digest(channel.to_s).unpack1("q>"), a signed 64-bit integer because Postgres and SQLite have no unsigned one. bin/rails runner 'puts Digest::SHA256.digest("ticker:eurusd").unpack1("q>")' prints 4284805293611697416, which is the value in the rows above.

The listener's query is the broadcastable scope, where(channel_hash: channel_hashes).where(id: (last_id.to_i + 1)..).order(:id), so the poll is an index range scan and not a table scan, and rows are deleted by SolidCable::TrimJob on a probability derived from the write count rather than on a schedule. What that adds up to: a per-message row on the write path, with message_retention: 1.day deciding how long the table carries it. A ticker broadcasting once a second writes 86,400 rows a day into the primary database of an application that may have nothing else to do with them.

500 connections cost memory, not threads

The frequent worry about WebSockets in Rails is that a long-lived connection holds a request thread. It does not. Puma hands the socket off, and the connection lives in Action Cable's event loop. This was measured on the development server with enable_reloading = false and eager_load = true so the numbers would stop moving, max_threads: 3, 500 clients opened from one process:

$ curl -s localhost:3487/cable_stats
{"open_connections":1,"worker_pool_size":4,"puma":{"running":3,"pool_capacity":2,"max_threads":3,"backlog":0,"requests_count":4005},"rss_kb":126192}

$ IDLE=1 ruby bin/hold 500 75
opened=500 confirmed=500

$ curl -s localhost:3487/cable_stats
{"open_connections":500,"worker_pool_size":4,"puma":{"running":3,"pool_capacity":2,"max_threads":3,"backlog":0,"requests_count":4506},"rss_kb":144016}

running 3, pool_capacity 2, backlog 0, with 500 sockets open. The pool is as free as it was at rest, and that is the answer to the thread question: none of them are in it. RSS went from 126,192 KB to 144,016 KB, about 36 KB for each connection added, and that figure is the softest number on this page because it is a Ruby heap that grows in steps rather than a per-object cost.

ab -n 1000 -c 4 against a plain text action, three runs each, same server:

0 connections:    585.59, 607.61, 603.16 req/s
500 connections:  570.78, 563.39, 572.94 req/s

About five percent, which the CPU table above accounts for: the heartbeat and the poller are both running in that second measurement and not in the first.

An earlier version of that comparison said throughput dropped by twenty percent, and it was wrong. The load generator holding the 500 sockets was sweeping all of them with IO.select on a 5 ms timeout, which on a 12 core laptop is enough CPU to slow down ab running beside it. Holding the sockets open and never reading them, which is what the IDLE=1 branch of bin/hold does, removed the effect. Two clients and a server competing for the same cores produce a number about the laptop, which is why every CPU figure here is ps -o cputime= on the server process rather than a rate measured from outside it.

The tests pass, and one of them cannot fail

Action Cable has two test base classes and they cover the useful ground. Eight examples, run against the adapter test:

class TickerChannelTest < ActionCable::Channel::TestCase
  def setup = stub_connection(current_user: "alice")

  test "subscribing with a room streams from that room only" do
    subscribe room: "eurusd"
    assert subscription.confirmed?
    assert_has_stream "ticker:eurusd"
    assert_has_no_stream "ticker:gbpusd"
  end

  test "the echo action broadcasts to the room" do
    subscribe room: "eurusd"
    assert_broadcast_on("ticker:eurusd", echo: "hi", from: "alice") do
      perform :echo, body: "hi"
    end
  end
end
8 runs, 11 assertions, 0 failures, 0 errors, 0 skips

What they do not cover is the failure this page opened with. ActionCable::Connection::TestCase builds the connection and calls connection.connect if connection.respond_to?(:connect) at actioncable-8.1.4/lib/action_cable/connection/test_case.rb:198. It never calls process, which is where allow_request_origin? lives. So this passes:

test "the connection test passes even when every origin is forbidden" do
  original = ActionCable.server.config.allowed_request_origins
  ActionCable.server.config.allowed_request_origins = []
  connect "/cable?token=tok-alice"
  assert_equal "alice", connection.current_user
ensure
  ActionCable.server.config.allowed_request_origins = original
end

A green connection suite proves the identification logic and says nothing about whether a browser can open the socket. The thing that catches that is a real client against a real server, which is what bin/frames above is for.

Without Action Cable at all

Action Cable's protocol, the subscription multiplexing, the heartbeat, the reconnect logic and the adapter are worth having, and some people arriving at this question do not want any of it. A Rack endpoint mounted in config/routes.rb is the alternative, and it is about fifteen lines with faye-websocket:

require "faye/websocket"

class RawSocket
  def self.call(env)
    return [ 426, { "content-type" => "text/plain" }, [ "upgrade required\n" ] ] unless Faye::WebSocket.websocket?(env)

    ws = Faye::WebSocket.new(env)
    ws.on(:open) { ws.send({ hello: "no action cable here" }.to_json) }
    ws.on(:message) { |e| ws.send({ echoed: e.data }.to_json) }
    ws.rack_response
  end
end
mount RawSocket => "/raw"

It works, on the same Puma, with no Origin header and no token:

 0.013s  {"hello":"no action cable here"}
 0.510s  {"echoed":"{\"ping\":\"hello\"}"}

curl localhost:3487/raw answers upgrade required. What you have given up is everything in the sections above: no Origin check, so any page on the internet can open that socket carrying the user's cookies, and writing that check is now yours; no heartbeat, so a proxy will close idle sockets and nothing will notice; no reconnection on the client; no way to reach that socket from another process, because there is no adapter, which puts you back at the async problem with no solid_cable to switch to. It is the right shape for a single endpoint with a protocol of its own, a machine-to-machine feed, a terminal streaming logs. It is the wrong shape for anything a browser full of components subscribes to.

What this page does not cover

No browser is involved anywhere above: every client here is a Ruby script, which is what made the frame traces readable and also what kept the JavaScript Consumer, Subscription and ConnectionMonitor out of scope beyond their two constants. Turbo Stream broadcasts ride on Action Cable and are not discussed; broadcast_append_to and its friends are a layer above everything here. The redis adapter was not measured at all, and neither was mounting Action Cable as its own Rack application on a separate port, which is the answer when /cable traffic has to scale away from the web processes. Nothing was run behind nginx, a load balancer or a CDN, so proxy idle timeouts and proxy_read_timeout are named only as the thing the 3 second heartbeat is there to survive. The 500 connection ceiling is not a ceiling: it is the number one client process could open comfortably, and the interesting limits (file descriptors, the worker_pool_size of 4 for channel actions, Postgres connections for Solid Cable) start mattering somewhere above it and were not found.

#rails #action-cable

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.