rubyguides

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

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

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