WebSockets in Ruby
WebSockets give you full-duplex communication over a single persistent TCP connection. Unlike HTTP, where the client always initiates requests, either side can send messages at any time. This makes 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+.
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
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:
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
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
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
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
See Also
- /guides/ruby-socket-programming/ — TCP/UDP socket fundamentals WebSockets build on
- /tutorials/ruby-async-gem/ — async gem basics used by async-websocket