Ruby WebSockets: Building Real-Time Features with ActionCable
Ruby WebSockets give you full-duplex communication over a single persistent TCP connection. Unlike HTTP, where the client always initiates requests, either side of a WebSocket connection can send messages at any time. This makes Ruby WebSockets the right tool for live chat, notifications, collaborative editors, and progress indicators that update without a page refresh.
Ruby does not include WebSocket support in its standard library, so you reach for a gem. The right gem depends on your stack. This guide covers three approaches: a bare-bones websocket-ruby server, Rails’ built-in ActionCable, and the async-native async-websocket for Ruby 3.0+.
Intro context
WebSockets are a good fit when your app needs a long-lived connection instead of a quick request/response cycle. That means the client can keep listening while the server pushes updates, which is ideal for dashboards, chat rooms, collaborative tools, and any interface that should feel live without constant polling.
The tradeoff is that the connection now has a lifecycle of its own. You need to think about heartbeats, reconnects, idle timeouts, and what happens when a process dies mid-message. Once you understand those moving parts, the three libraries in this guide become easier to compare because they solve different pieces of that lifecycle.
If you want a broader mental model, it helps to compare WebSockets with ordinary Ruby socket programming. The lower-level sockets teach you about streams and connections, while WebSockets add message framing and browser-friendly semantics on top.
This guide compares the simplest Ruby option with the Rails and fiber-based alternatives so you can pick the right runtime model for your app.
websocket-ruby: pure Ruby, no dependencies
The websocket-ruby gem works anywhere Ruby runs. It has no native extensions and does not require Rails.
Server
require "websocket"
server = WebSocket::Server.new(port: 8080)
loop do
conn = server.accept
Thread.new(conn) do |c|
c.handshake
# Heartbeat: ping every 30 seconds to keep the connection alive
heartbeat = Thread.new(c) do |socket|
loop do
sleep 30
socket.ping(socket.object_id.to_s) rescue break
end
end
while (msg = c.gets)
c.write("Echo: #{msg}") unless c.closed?
end
heartbeat.kill
c.close
end
end
WebSocket::Server.new accepts a port, optional host, and SSL options. Each connection runs in its own thread because gets is blocking. The unless c.closed? guard matters here—Ruby sockets do not raise immediately when the remote end closes; writing to a closed connection raises IOError.
The heartbeat thread runs concurrently with the message loop. If the socket raises during a ping (because the remote end died), rescue break exits the loop cleanly. The main thread kills the heartbeat when the connection closes.
Client
The client side is simpler than the server because it connects to a known endpoint and exchanges messages rather than managing multiple incoming connections. The constructor accepts either a URI object or a plain string, and the handshake call completes the WebSocket upgrade from the initial HTTP request. After that, the client can both write messages and read responses through the same socket. The loop here reads one frame at a time with gets, which blocks until a message arrives or the connection closes. If you need to send messages from user input or a timer while still reading, you should run the read loop and the write calls in separate threads so neither side stalls the other.
require "websocket"
client = WebSocket::Client.new(URI.parse("ws://localhost:8080/chat"))
client.handshake
client.write("Hello, server!")
loop do
puts client.gets
end
client.close
The client constructor takes a URI object or a string. Use ws:// for plain connections and wss:// for TLS.
EventMachine adapter
For event-loop concurrency without threads, pair websocket-ruby with eventmachine. The EventMachine reactor runs in a single thread and dispatches events through callbacks, which keeps the memory footprint low when you have many concurrent connections. Each callback (onopen, onmessage, onclose, onerror) fires when the corresponding WebSocket event occurs, so the server code reads as a series of event handlers rather than a blocking loop. This model handles close events properly where the bare websocket-ruby server does not, and it scales better for apps that need dozens or hundreds of simultaneous sockets. You need both the websocket-ruby and eventmachine gems in your Gemfile.
require "websocket/eventmachine"
EventMachine.run do
EventMachine::WebSocket.start(host: "0.0.0.0", port: 8080) do |ws|
ws.onopen { |handshake| puts "Connected: #{handshake.path}" }
ws.onmessage { |msg| ws.send("Echo: #{msg}") }
ws.onclose { puts "Connection closed" }
ws.onerror { |e| puts "Error: #{e.message}" }
end
end
This handles close events properly, unlike the bare websocket-ruby server. You need both the websocket-ruby and eventmachine gems.
ActionCable: websockets inside Rails
ActionCable ships with Rails 5 and later. It integrates WebSocket connections into the Rails app’s request lifecycle and uses a pub/sub adapter to broadcast messages to subscribers.
Connection
The connection handles authentication when a client opens a WebSocket:
# 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
end
private
def find_verified_user
User.find_by(id: cookies.encrypted[:user_id]) ||
reject_unauthorized_connection
end
end
end
identified_by declares an attribute that uniquely identifies this connection. You cannot change it after connect runs.
Channel
Channels define the pub/sub streams clients subscribe to:
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_#{params[:room_id]}"
end
def unsubscribed
stop_all_streams
end
def speak(data)
ActionCable.server.broadcast(
"chat_#{data["room_id"]}",
{ message: data["message"], user: current_user.name }
)
end
end
stream_from registers the channel for a named stream. When something broadcasts to that stream, ActionCable sends the data to every connected client in that channel. stream_for(record) is a shortcut that generates the stream name from an Active Record model.
Broadcasting from anywhere
The real power of ActionCable shows up when you broadcast from outside a channel handler. Any part of your Rails application can push messages to connected clients through the same pub/sub mechanism, which means a background job that finishes processing a report can notify the user’s browser without the browser polling for status. A controller that receives a webhook can forward the payload directly to subscribed clients. And a model callback can broadcast a change event so every open dashboard updates in real time. The key is that the broadcast call does not need to know who is listening, it just publishes to a named stream and ActionCable delivers the message to every subscriber.
You can broadcast from controllers, background jobs, or models:
ActionCable.server.broadcast(
"chat_#{room_id}",
{ message: "New message", user: "Alice" }
)
Client-side JavaScript
import { createConsumer } from "@rails/actioncable"
const cable = createConsumer("ws://localhost:3000/cable")
cable.subscriptions.create("ChatChannel", {
received(data) {
console.log(data.message)
},
speak(message) {
return this.perform("speak", { message })
}
})
Channel#perform calls the corresponding method on the server-side channel with the passed data as params.
Adapter configuration
Choosing the right adapter is the difference between a WebSocket feature that works in development and one that works in production. The inline adapter keeps broadcasts inside a single process, which is fine when you have one Puma worker and one Rails console session. But as soon as you deploy with multiple Puma workers, a broadcast from worker A never reaches clients connected to worker B unless the adapter spans processes. Redis solves this by acting as the shared message bus. Every Puma worker subscribes to the same Redis channels, so a broadcast from any worker reaches every client regardless of which process they connected to.
ActionCable needs a pub/sub adapter. In development with a single process, the inline adapter works fine. For production with multiple Puma workers, you need Redis:
# config/cable.yml
production:
adapter: redis
url: redis://localhost:6379/1
channel_prefix: myapp_cable
Without Redis, broadcasts only reach clients connected to the same process. Since Rails 5.1, you also need to explicitly allow cross-origin requests:
# config/application.rb
config.action_cable_allowed_request_origins = [/https:\/\/yourdomain\.com/]
ActionCable on multiple processes
If you deploy Puma with multiple workers, ActionCable’s in-memory pub/sub only reaches clients on the same worker process. Set adapter: redis in config/cable.yml for cross-process broadcasting.
async-websocket: non-blocking for Ruby 3.0+
The async-websocket gem works with Ruby 3.0+ and the async gem to handle many concurrent connections in a single thread using fibers.
Server
require "async"
require "async/websocket/server"
Async do
server = Async::WebSocket::Server.new(host: "0.0.0.0", port: 8080)
server.each do |connection|
Async do |task|
connection.write({ type: "welcome", message: "Connected!" }.to_json)
connection.each do |message|
data = JSON.parse(message)
puts "Received: #{data.inspect}"
connection.write({ echo: data }.to_json)
end
ensure
connection.close
end
end
end
Each connection runs in its own async task. The block form of connection.each suspends the fiber while waiting for messages, leaving other connections free to run. This model handles thousands of concurrent connections far more efficiently than one thread per connection.
Client
The async-websocket client follows the same fiber-based pattern as the server. The Async block creates a reactor that runs the client connection inside a fiber, and the each iterator suspends while waiting for the next message. Because the fiber yields control instead of blocking a thread, you can open many client connections from a single process without the per-connection thread overhead you would get with websocket-ruby. The ensure clause closes the connection when the block exits, which handles both normal completion and exceptions the same way. This is a clean pattern for writing integration tests that need to simulate many WebSocket clients talking to the same server.
require "async"
require "async/websocket/client"
Async do
client = Async::WebSocket::Client.open("ws://localhost:8080/chat")
client.write({ message: "Hello!" }.to_json)
client.each do |message|
puts "Received: #{message}"
end
ensure
client.close
end
Common pitfalls
Closed connections and IOError
With bare websocket-ruby, writing to a connection after the remote client closes raises IOError: closed stream. The EM adapter and async-websocket handle this more gracefully. Always check conn.closed? before writing in bare websocket-ruby:
c.write("ping") unless c.closed?
Connection drops from load balancers
HTTP proxies and load balancers often close idle connections after 30–60 seconds. If neither side sends a frame, the connection disappears silently. Implement a heartbeat as shown in the server example above — a separate thread that pings every 30 seconds prevents proxies from timing out the connection.
Blocking I/O
The bare websocket-ruby gets method blocks the thread it runs in. One thread per connection is fine for dozens of connections, but it does not scale to hundreds. For that, use async-websocket or eventmachine.
No automatic reconnection
WebSockets do not reconnect automatically when the connection drops. Your client code needs to track connection state and retry with backoff:
def connect
@attempts ||= 0
@attempts += 1
@ws = WebSocket::Client.new(URI.parse(@url))
@ws.handshake
rescue
sleep(2 ** [@attempts - 2, 0].max)
retry
end
The first retry fires immediately, then backoff doubles on each subsequent failure: 1s, 2s, 4s, and so on.
Which library to choose
| Gem | Best For |
|---|---|
websocket-ruby | Pure Ruby, no framework, quick prototypes |
eventmachine + websocket-ruby | Event-loop server with many connections |
| ActionCable | Rails apps that need WebSockets integrated with the app |
async-websocket | High-concurrency servers on Ruby 3.0+ |
Summary
- WebSockets provide persistent bidirectional communication between client and server
websocket-rubygives you a pure-Ruby server and client with no dependencies- ActionCable integrates WebSockets into Rails with channels, streams, and a pub/sub broadcast system
async-websockethandles thousands of concurrent connections efficiently using Ruby 3.0 fibers- Guard writes with
conn.closed?in barewebsocket-ruby; use heartbeats to prevent proxy timeouts - Scale ActionCable across processes with a Redis adapter
Forward link
Once you know how the connection models differ, the next step is to decide how real-time data should move through the rest of your app. In Rails, that often means combining WebSockets with background jobs, database events, or a small amount of stateful application logic so the socket layer stays focused on delivery.
See Also
- /guides/ruby-socket-programming/ — TCP/UDP socket fundamentals WebSockets build on
- /tutorials/ruby-concurrency/ruby-async-gem/ — async gem basics used by async-websocket