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.
Comments
No comments yet. Be the first.