WebSockets in Ruby

· 6 min read · Updated April 1, 2026 · intermediate
ruby websockets real-time networking actioncable

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

GemBest For
websocket-rubyPure Ruby, no framework, quick prototypes
eventmachine + websocket-rubyEvent-loop server with many connections
ActionCableRails apps that need WebSockets integrated with the app
async-websocketHigh-concurrency servers on Ruby 3.0+

Summary

  • WebSockets provide persistent bidirectional communication between client and server
  • websocket-ruby gives 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-websocket handles thousands of concurrent connections efficiently using Ruby 3.0 fibers
  • Guard writes with conn.closed? in bare websocket-ruby; use heartbeats to prevent proxy timeouts
  • Scale ActionCable across processes with a Redis adapter

See Also