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 open
HTTP 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 like websocket-driver or faye-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 --> A

ActionCable 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 -.-> S2

Without 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 #

LibraryDependencyStrengthsWeaknessesBest for
ActionCableRailsRails integration, channel system, easy broadcastingRails onlyRails applications
faye-websocketEventMachineMature, flexible, standaloneEventMachine can be hard to debugNon-Rails Rack apps
websocket-driverNoneVery flexible, usable anywhereLow-level, more work neededLibraries needing WS
IodineNoneWeb server and WS server in oneLess popularHigh-performance Ruby
AnyCableRails + extActionCable protocol, multi-languageMore complex setupScaling 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 async adapter 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 model vs stream_from stringstream_for is safer because the stream name is derived from the model, preventing collisions; use stream_from only when you truly need a custom name.

← Previous: Sockets   Next: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact