WebSocket #
WebSocket is a protocol that fundamentally changes how web applications communicate. Traditional HTTP is request-response — the client asks, the server answers, the connection ends. WebSocket opens a permanent channel between client and server: once the connection is established, either side can send data at any time without starting a new request. This is the foundation of modern real-time applications — live chat, push notifications, auto-updating dashboards, document collaboration, online games, and stock price feeds. In Ruby, there are several options: Faye::WebSocket running on EventMachine, the more flexible websocket-driver gem, or ActionCable fully integrated with Rails.
HTTP vs WebSocket — Fundamental Differences #
sequenceDiagram
participant B as Browser
participant S as Server
note over B,S: Traditional HTTP — Request/Response
B->>S: GET /data HTTP/1.1
S->>B: 200 OK + data
note over B,S: Connection done
B->>S: GET /data HTTP/1.1 (polling)
S->>B: 200 OK + data (maybe nothing new)
note over B,S: Polling = wasteful bandwidth
note over B,S: WebSocket — Permanent Channel
B->>S: HTTP Upgrade Request
S->>B: 101 Switching Protocols
note over B,S: WebSocket connection open
S->>B: Data (anytime, without a request)
B->>S: Data (anytime, bidirectional)
S->>B: Real-time notification
B->>S: Command from the user
note over B,S: Connection stays openHTTP Polling vs WebSocket:
HTTP Polling:
✗ The client must constantly ask "any new data?"
✗ HTTP overhead per request (headers, TCP handshake)
✗ High latency — new data must wait for the next poll
✗ The server is burdened by requests that often return nothing
WebSocket:
✓ The server can push data anytime without being asked
✓ Minimal overhead after the connection is open
✓ Very low latency — data is sent immediately
✓ One long-lived TCP connection
✗ Persistent connections consume server resources
✗ More complex than simple HTTP
The WebSocket Protocol — How It Works #
WebSocket starts as a regular HTTP connection, then is upgraded through a mechanism called the handshake:
CLIENT → SERVER (HTTP Upgrade Request):
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
SERVER → CLIENT (HTTP 101 Switching Protocols):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After this: the connection becomes WebSocket, HTTP is no longer used.
Data is sent in binary frames, not HTTP.
After the handshake, data is sent in frames — the smallest unit of WebSocket communication containing information about whether it’s text or binary, whether this is the final frame of a message, and the actual data.
Manual WebSocket Implementation from TCP #
To understand how WebSocket works from the ground up, we can implement the handshake and framing manually:
require 'socket'
require 'digest'
require 'base64'
WEBSOCKET_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def create_accept_key(client_key)
Base64.strict_encode64(
Digest::SHA1.digest(client_key.strip + WEBSOCKET_MAGIC)
)
end
def perform_handshake(client)
request = ""
while (line = client.gets) && line != "\r\n"
request += line
end
# Extract the WebSocket key from the header
key = request.match(/Sec-WebSocket-Key: (.+)/)[1]&.strip
accept = create_accept_key(key)
# Send the upgrade response
client.write [
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Accept: #{accept}",
"",
""
].join("\r\n")
puts "WebSocket handshake successful"
end
def read_frame(client)
byte1 = client.getbyte
return nil if byte1.nil?
byte2 = client.getbyte
masked = (byte2 & 0x80) != 0
length = byte2 & 0x7F
# Handle extended payload lengths
length = case length
when 126 then client.read(2).unpack1("n")
when 127 then client.read(8).unpack1("Q>")
else length
end
mask = masked ? client.read(4).bytes : nil
data = client.read(length).bytes
# Unmask the data
if masked
data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
end
data.pack("C*").force_encoding("UTF-8")
end
def send_frame(client, message)
data = message.encode("UTF-8").bytes
length = data.length
frame = [0x81] # FIN bit + text opcode (0x1)
if length <= 125
frame << length
elsif length <= 65535
frame << 126
frame += [length].pack("n").bytes
else
frame << 127
frame += [length].pack("Q>").bytes
end
frame += data
client.write(frame.pack("C*"))
end
# Manual WebSocket server
server = TCPServer.new("localhost", 8080)
puts "Manual WebSocket server running on ws://localhost:8080"
loop do
client = server.accept
Thread.new(client) do |conn|
begin
perform_handshake(conn)
send_frame(conn, "Welcome to the manual WebSocket server!")
loop do
message = read_frame(conn)
break if message.nil?
puts "Received: #{message}"
send_frame(conn, "Echo: #{message}")
end
rescue => e
puts "Error: #{e.message}"
ensure
conn.close
end
end
end
The manual implementation above is useful for understanding the protocol, but for production always use a battle-tested library likewebsocket-driverorfaye-websocket. The WebSocket protocol has many edge cases (fragmented frames, ping/pong, close handshake) that are hard to implement correctly from scratch.
Faye::WebSocket — Standalone WebSocket Server #
faye-websocket is a mature gem for WebSocket outside Rails, running on EventMachine (a non-blocking event loop):
gem install faye-websocket eventmachine
Echo Server with Faye #
# websocket_server.rb
require 'faye/websocket'
require 'eventmachine'
require 'json'
EM.run do
clients = {} # ws => { id:, name: }
app = lambda do |env|
# Check whether this is a WebSocket request
unless Faye::WebSocket.websocket?(env)
return [200, {"Content-Type" => "text/plain"}, ["Not a WebSocket request"]]
end
ws = Faye::WebSocket.new(env)
ws.on :open do |event|
id = SecureRandom.hex(4)
clients[ws] = { id: id, name: "User-#{id}" }
puts "Client connected: #{id}"
ws.send(JSON.generate({ type: "welcome", id: id, message: "Welcome!" }))
end
ws.on :message do |event|
data = JSON.parse(event.data) rescue { "type" => "text", "message" => event.data }
sender = clients[ws]
puts "[#{sender[:name]}] #{data['message']}"
# Broadcast to all connected clients
broadcast = JSON.generate({
type: "message",
sender: sender[:name],
message: data["message"],
time: Time.now.strftime("%H:%M:%S")
})
clients.each_key { |client_ws| client_ws.send(broadcast) }
end
ws.on :close do |event|
info = clients.delete(ws)
puts "Client disconnected: #{info&.dig(:name)} (#{event.code}: #{event.reason})"
ws = nil
end
ws.on :error do |event|
puts "Error: #{event.message}"
end
# Important: return the async response object
ws.rack_response
end
# Run with Thin or Puma
Rack::Server.start(app: app, port: 8080, server: "thin")
end
# Run the server
ruby websocket_server.rb
# Or with thin directly
thin start -R websocket_server.rb -p 8080
WebSocket Client with Faye #
# websocket_client.rb
require 'faye/websocket'
require 'eventmachine'
require 'json'
EM.run do
ws = Faye::WebSocket::Client.new("ws://localhost:8080")
ws.on :open do |event|
puts "Connected to the server!"
ws.send(JSON.generate({ type: "message", message: "Hello from the Ruby client!" }))
end
ws.on :message do |event|
data = JSON.parse(event.data)
puts "[#{data['sender'] || 'Server'}] #{data['message']}"
end
ws.on :close do |event|
puts "Disconnected: #{event.code} - #{event.reason}"
EM.stop
end
# Send a message every 3 seconds
EM.add_periodic_timer(3) do
ws.send(JSON.generate({ type: "ping", message: "Ping #{Time.now.to_i}" }))
end
end
ActionCable — WebSocket Integrated with Rails #
ActionCable is Rails’ built-in WebSocket solution that integrates real-time communication with the Rails ecosystem — models, jobs, and broadcasting — seamlessly.
ActionCable Architecture #
flowchart TD
A["Browser / Client"] --> B[ActionCable Connection]
B --> C[Channel Subscription]
C --> D[ChatChannel]
C --> E[NotificationChannel]
D --> F[ActiveRecord Model]
D --> G["ActionCable.server.broadcast"]
G --> H["Pub/Sub Backend"]
H --> I["Redis (Production)"]
H --> J["Async (Development)"]
I --> G2[Broadcast to all subscribers]
G2 --> AActionCable Setup #
ActionCable is already available in Rails 5+ without additional installation. Minimal configuration:
# config/cable.yml
development:
adapter: async # in-memory, only for a single server
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: myapp_production # avoid conflicts when sharing Redis
# config/routes.rb
Rails.application.routes.draw do
mount ActionCable.server => "/cable" # WebSocket endpoint
# ... other routes
end
Connection — WebSocket Authentication #
ApplicationCable::Connection is the gateway — this is where authentication happens before a connection is accepted:
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user # unique identifier per connection
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# Find the user from the session cookie
if (user_id = cookies.encrypted[:user_id])
User.find_by(id: user_id) || reject_unauthorized_connection
# Or from a JWT token in the query string
elsif (token = request.params[:token])
verify_jwt(token) || reject_unauthorized_connection
else
reject_unauthorized_connection
end
end
def verify_jwt(token)
payload = JWT.decode(token, Rails.application.secret_key_base).first
User.find_by(id: payload["user_id"])
rescue JWT::DecodeError
nil
end
end
end
Channels — Real-time Business Logic #
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
# Called when a client subscribes to this channel
def subscribed
room = params[:room]
reject unless room.present?
# Verify the user is allowed to enter this room
@room = Room.find_by(slug: room)
reject unless @room && current_user.can_enter?(@room)
# Subscribe to the broadcasting stream for this room
stream_for @room # automatically creates a stream name from the model
# or: stream_from "chat:#{@room.id}"
# Notify the channel that a user joined
ActionCable.server.broadcast(
"chat:#{@room.id}",
{ type: "joined", user: current_user.name, time: Time.now }
)
end
# Called when a client unsubscribes or disconnects
def unsubscribed
if @room
ActionCable.server.broadcast(
"chat:#{@room.id}",
{ type: "left", user: current_user.name, time: Time.now }
)
end
end
# Called from the client: App.chatChannel.perform('send_message', {text: '...'})
def send_message(data)
text = data["text"]&.strip
return unless text.present?
return if text.length > 500
message = @room.messages.create!(
user: current_user,
text: text
)
# Broadcast to all room subscribers
ActionCable.server.broadcast(
"chat:#{@room.id}",
{
type: "new_message",
id: message.id,
user: current_user.name,
avatar_url: current_user.avatar_url,
text: message.text,
time: message.created_at.strftime("%H:%M")
}
)
end
# Typing indicator — no need to save to the DB
def typing
ActionCable.server.broadcast(
"chat:#{@room.id}",
{ type: "typing", user: current_user.name }
)
end
end
Broadcasting from Anywhere #
One of ActionCable’s strengths is that you can broadcast from anywhere — model callbacks, background jobs, controllers:
# From a model callback (e.g. after an order is created)
class Order < ApplicationRecord
after_create_commit :broadcast_new_order
private
def broadcast_new_order
ActionCable.server.broadcast(
"admin_dashboard",
{
type: "new_order",
id: self.id,
total: self.total,
status: self.status
}
)
end
end
# From a background job (Sidekiq/ActiveJob)
class NotificationJob < ApplicationJob
def perform(user_id, message)
ActionCable.server.broadcast(
"user_notifications_#{user_id}",
{ type: "notification", message: message, time: Time.now }
)
end
end
# From a controller
class OrdersController < ApplicationController
def update
@order.update!(status: params[:status])
# Broadcast the update to the dashboard
ActionCable.server.broadcast(
"order_#{@order.id}",
{ status: @order.status, updated_at: @order.updated_at }
)
render json: @order
end
end
ActionCable JavaScript Client #
// app/javascript/channels/chat_channel.js
import consumer from "./consumer"
let chatChannel = null
function joinRoom(roomSlug) {
// Unsubscribe from the previous room if any
chatChannel?.unsubscribe()
chatChannel = consumer.subscriptions.create(
{ channel: "ChatChannel", room: roomSlug },
{
connected() {
console.log("Connected to room:", roomSlug)
showStatus("Connected")
},
disconnected() {
console.log("Disconnected from room:", roomSlug)
showStatus("Disconnected — trying to reconnect...")
},
received(data) {
switch (data.type) {
case "new_message":
showMessage(data)
break
case "joined":
showSystem(`${data.user} joined the room`)
break
case "left":
showSystem(`${data.user} left the room`)
break
case "typing":
showTypingIndicator(data.user)
break
}
},
// Methods callable from other code
sendMessage(text) {
this.perform("send_message", { text })
},
typing() {
this.perform("typing")
}
}
)
}
// Example usage
joinRoom("general")
document.getElementById("message-form").addEventListener("submit", (e) => {
e.preventDefault()
const input = document.getElementById("message-input")
chatChannel.sendMessage(input.value)
input.value = ""
})
document.getElementById("message-input").addEventListener("input", () => {
chatChannel.typing()
})
Heartbeat — Keeping the Connection Alive #
WebSocket connections can be dropped by proxies or firewalls that close idle connections. Heartbeat ping/pong prevents this:
# ActionCable already has a built-in heartbeat
# Configure the interval (default 3 seconds):
ActionCable.server.config.ping_interval = 3
# For a Faye/custom server, manual implementation:
EM.add_periodic_timer(30) do
clients.each_key do |ws|
ws.ping("heartbeat") do |success|
unless success
puts "Client didn't respond to ping, closing the connection"
ws.close
end
end
end
end
// JavaScript client — automatic reconnection
class ReliableWebSocket {
constructor(url) {
this.url = url
this.reconnectDelay = 1000 // start at 1 second
this.maxDelay = 30000 // maximum 30 seconds
this.connect()
}
connect() {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
console.log("WebSocket connected")
this.reconnectDelay = 1000 // reset the delay on success
}
this.ws.onmessage = (event) => {
this.onmessage(JSON.parse(event.data))
}
this.ws.onclose = (event) => {
if (!event.wasClean) {
console.log(`Disconnected, trying to reconnect in ${this.reconnectDelay}ms`)
setTimeout(() => this.connect(), this.reconnectDelay)
// Exponential backoff
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay)
}
}
this.ws.onerror = (error) => {
console.error("WebSocket error:", error)
}
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
} else {
console.warn("Cannot send, WebSocket is not open yet")
}
}
onmessage(data) {
// Override in a subclass or instance
console.log("Message received:", data)
}
}
Real-time Notifications — A Common Use Case #
A common pattern for per-user notification systems with ActionCable:
# app/channels/notification_channel.rb
class NotificationChannel < ApplicationCable::Channel
def subscribed
stream_for current_user # dedicated stream per user
end
def unsubscribed; end
def mark_read(data)
notif = current_user.notifications.find(data["id"])
notif.update!(read: true)
end
end
# Helper module for broadcasting notifications to a user
module NotificationHelper
def self.send_to(user, message:, type: :info, link: nil)
notif = user.notifications.create!(
message: message,
type: type,
link: link
)
NotificationChannel.broadcast_to(
user,
{
id: notif.id,
message: message,
type: type,
link: link,
time: notif.created_at.strftime("%H:%M")
}
)
end
end
# Usage from anywhere
NotificationHelper.send_to(
user,
message: "Order ##{order.id} has been confirmed",
type: :success,
link: "/orders/#{order.id}"
)
Scaling WebSocket with Redis #
When an application runs on multiple servers (multiple Puma workers or Heroku dynos), WebSocket connections are spread across different servers. Redis pub/sub becomes the bridge between servers:
# config/cable.yml
production:
adapter: redis
url: <%= ENV["REDIS_URL"] %>
channel_prefix: <%= Rails.env %>
# With Redis Sentinel for high availability
production:
adapter: redis
url: redis://localhost:26379/1
sentinels:
- host: sentinel1.example.com
port: 26379
- host: sentinel2.example.com
port: 26379
role: :master
How Redis helps with scaling:
flowchart TD
subgraph Server1 ["Server 1 (Puma)"]
UA["User A connected & broadcasts a message"]
S1["Server 1: receives broadcast -> sends to User A"]
end
subgraph Server2 ["Server 2 (Puma)"]
UB["User B connected"]
S2["Server 2: receives broadcast -> sends to User B"]
end
Redis["Redis Pub/Sub"]
UA --> Redis
UB --> Redis
Redis -.-> S1
Redis -.-> S2Without Redis: a message from Server 1 never reaches User B on Server 2 With Redis: all servers subscribe to the same channel
Comparison of Ruby WebSocket Libraries #
| Library | Dependency | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| ActionCable | Rails | Rails integration, channel system, easy broadcasting | Rails only | Rails applications |
| faye-websocket | EventMachine | Mature, flexible, standalone | EventMachine can be hard to debug | Non-Rails Rack apps |
| websocket-driver | None | Very flexible, usable anywhere | Low-level, more work needed | Libraries needing WS |
| Iodine | None | Web server and WS server in one | Less popular | High-performance Ruby |
| AnyCable | Rails + ext | ActionCable protocol, multi-language | More complex setup | Scaling Rails WebSocket |
Summary #
- WebSocket for real-time, HTTP for request/response — choose WebSocket only when you need continuous bidirectional communication; for infrequently updated data, Server-Sent Events (SSE) or polling can be simpler.
- ActionCable for Rails — already integrated, has a channel system, broadcasting from models/jobs/controllers, and session-based authentication.
- Authenticate in
Connection#connect— verify the user’s identity before accepting the connection; don’t keep unauthenticated state.- Broadcast from background jobs — for time-consuming operations, do them in a Sidekiq job and broadcast the result to clients; don’t block the HTTP request waiting.
- The Redis adapter is required in production — the
asyncadapter is only for development; production with multiple servers needs Redis as the pub/sub backend.- Heartbeat prevents dropped connections — proxies and firewalls often close idle connections; ActionCable has a built-in heartbeat, but make sure the client side also handles reconnection.
- Exponential backoff for reconnection — clients that reconnect immediately after a drop can overwhelm the server; use an increasing delay to prevent a thundering herd.
- Channel prefix in Redis — avoid conflicts when sharing the same Redis across several environments or applications.
- Manual implementation is only for understanding — the WebSocket protocol has many edge cases; for production always use a battle-tested library.
stream_for modelvsstream_from string—stream_foris safer because the stream name is derived from the model, preventing collisions; usestream_fromonly when you truly need a custom name.