From Theory to Production: Reliable WebSockets with Ruby on Rails, JWT & ActionCable
“WebSockets are easy… until you have to make them reliable and scalable.”
Most WebSocket tutorials stop at building a simple chat demo. But real systems need authentication, reliability, scalability, and good architecture.
In this post we’ll connect:
- The WebSocket fundamentals & best practices from three in-depth articles fileciteturn0file0turn0file1turn0file2
- A real-world Rails API backend using Devise + JWT
- ActionCable as the WebSocket layer
- Concrete code examples
- Extra use cases, WebSocket vs pub/sub comparisons, and sharding/scaling patterns
1. WebSockets in a Nutshell (For Rails Developers)
1.1 What is a WebSocket?
A WebSocket is a full-duplex, persistent TCP connection between client and server. After a one-time HTTP handshake (using Upgrade: websocket), both sides can send messages at any time, without repeated HTTP requests. fileciteturn0file0
Compared to classic HTTP:
| Feature | HTTP | WebSocket |
|---|---|---|
| Connection | Short-lived per request | Long-lived, persistent |
| Direction | Client → Server (request) | Bidirectional |
| Latency | Higher (headers + handshakes) | Lower after initial upgrade |
| Model | Request/response | Event/message-based |
WebSockets shine in realtime use cases such as chat, multiplayer games, collaborative editing, IoT telemetry, live dashboards, and more. fileciteturn0file0
1.2 The Less Glamorous Bits: Reliability & Scaling
Out of the box, WebSockets don’t give you: fileciteturn0file1turn0file2
- Delivery guarantees (messages may be lost on disconnect)
- Message ordering guarantees
- Automatic reconnection
- Backpressure / rate limiting
- Fault tolerance across multiple servers
At scale, you also run into: fileciteturn0file2
- Stateful connections (harder load balancing than stateless HTTP)
- N² message explosion (many clients talking to many clients)
- Backpressure (slow consumers, fast producers)
- Global distribution & redundancy requirements
The WebSocket articles break these down into architectural and operational best practices: sharding, sticky sessions, pub/sub, backpressure, monitoring, and fault tolerance. fileciteturn0file2
The good news: Rails + ActionCable + Redis give us a solid foundation. We just need to wire things correctly and add a few patterns.
2. WebSockets vs Pub/Sub (and HTTP): Who Does What?
It’s easy to confuse WebSockets and pub/sub as alternatives. In reality they solve different layers of the problem.
- HTTP – Request/response, great for CRUD APIs, not great for realtime.
- WebSocket – A transport for bidirectional, low-latency communication. fileciteturn0file0
- Pub/Sub – A messaging pattern (and often a dedicated system) for fanning messages out to many consumers. fileciteturn0file2
Think of it like this:
- WebSocket: “How do I keep the pipe open between browser and backend?”
- Pub/Sub: “How do I deliver the right messages to the right set of subscribers?”
A typical production system uses both:
- Browsers connect to your backend over WebSockets.
- Each backend node connects to a pub/sub layer (Redis, Kafka, Ably, etc.). fileciteturn0file2
- When any node publishes an event to a topic, the pub/sub layer delivers it to all interested nodes, which then forward it to their connected WebSocket clients.
2.1 Comparison Table
| Aspect | WebSocket | Pub/Sub |
|---|---|---|
| Scope | Transport between one client & one server | Logical messaging between many producers/consumers |
| Direction | Bidirectional | Usually one-way per message (publish → subscribe) |
| Responsibility | Keep connection open & deliver raw frames | Route messages to interested subscribers |
| Scaling focus | Concurrent connections, keep-alives | Fan-out, throughput, durability, ordering |
| In Rails | ActionCable connections & channels | Redis ActionCable adapter, message buses |
In Rails, ActionCable + Redis is basically WebSocket + pub/sub out of the box. ActionCable uses Redis to broadcast between server processes, following the pub/sub architecture pattern recommended in the best-practices article. fileciteturn0file2
3. Rails Stack Overview
We’ll assume a stack like this:
- Rails API-only app
- Devise + devise-jwt for authentication
- ActionCable for WebSockets
- Redis as ActionCable’s pub/sub backend
- A frontend (Rails, React, Vue, etc.) that:
- Uses JWT for HTTP API auth
- Uses the same JWT to authenticate WebSocket connections
Let’s wire this up step-by-step.
4. Authenticating WebSockets with Devise + JWT
The articles highlight that WebSocket itself doesn’t define an auth mechanism — it’s up to you to layer one on top (cookies, JWT, etc.). fileciteturn0file0
In a Rails API-only app, JWT is a natural choice.
4.1 Pass JWT to ActionCable
Frontend connects like this:
// Example with ActionCable JS (Rails 7+)
import * as ActionCable from "@rails/actioncable";
const jwt = localStorage.getItem("jwt"); // issued by Devise + devise-jwt
const cable = ActionCable.createConsumer(
`wss://api.example.com/cable?token=${encodeURIComponent(jwt)}`
);
const subscription = cable.subscriptions.create(
{ channel: "ChatChannel", room_id: 42 },
{
received: (data) => console.log("New message:", data),
}
);
We attach the JWT as a query parameter (token=) so it’s accessible during the WebSocket handshake.
4.2 Decode JWT in ApplicationCable::Connection
ApplicationCable::Connection is the entry point for all WebSocket connections. This is where we validate the JWT and reject unauthorized users.
# 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
token = request.params[:token]
raise "Missing token" if token.blank?
# Using devise-jwt under the hood
payload = Warden::JWTAuth::TokenDecoder.new.call(token)
user = User.find_by(id: payload["sub"])
user || reject_unauthorized_connection
rescue => e
Rails.logger.warn("[ActionCable] Unauthorized connection: #{e.message}")
reject_unauthorized_connection
end
end
end
This gives us:
- A per-connection
current_user - The same identity model as our HTTP API
It follows the article’s recommendation to handle authentication at the handshake and/or application levels. fileciteturn0file0
5. ActionCable Channels: From Chat to General Realtime
Let’s start with the “classic” chat example, then generalize.
5.1 Basic Chat Channel
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
@room = Room.find(params[:room_id])
# Authorization example
reject unless @room.users.include?(current_user)
stream_for @room
end
def receive(data)
# Simple example: create message & broadcast
message = @room.messages.create!(
user: current_user,
content: data["content"]
)
ChatChannel.broadcast_to(
@room,
{
id: message.id,
content: message.content,
user_id: current_user.id,
created_at: message.created_at.iso8601
}
)
end
end
Corresponding model:
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :room
belongs_to :user
validates :content, presence: true
end
This gives you a basic authenticated chat over WebSockets. But to align with the articles’ reliability recommendations, we need to go further.
6. Reliability Patterns in Rails (From the Articles)
6.1 Heartbeats & Reconnection
ActionCable already supports heartbeat pings and automatic reconnection in the JS client, matching the best practice of keep-alives and reconnection logic. fileciteturn0file2
On the client (simplified):
// ActionCable automatically tries to reconnect on close.
// You can add logging:
cable.connection.monitor.reconnectAttempts = 0;
const originalDisconnected = cable.connection.disconnected;
cable.connection.disconnected = function(...args) {
cable.connection.monitor.reconnectAttempts += 1;
console.warn("WebSocket disconnected – attempt",
cable.connection.monitor.reconnectAttempts);
originalDisconnected.apply(this, args);
};
6.2 Backpressure & Rate Limiting
The articles emphasize backpressure and throttling “greedy clients”. fileciteturn0file2
Rails doesn’t do this automatically; you can add simple rate limiting per connection.
# app/channels/application_cable/channel.rb
module ApplicationCable
class Channel < ActionCable::Channel::Base
RATE_LIMIT_WINDOW = 5.seconds
RATE_LIMIT_MAX = 20 # messages per window
def rate_limited?
key = "ws:rate_limit:#{current_user.id}:#{self.class.name}"
data = Rails.cache.read(key) || { count: 0, started_at: Time.current }
if Time.current - data[:started_at] > RATE_LIMIT_WINDOW
data = { count: 0, started_at: Time.current }
end
data[:count] += 1
Rails.cache.write(key, data, expires_in: RATE_LIMIT_WINDOW * 2)
data[:count] > RATE_LIMIT_MAX
end
end
end
Use inside a channel:
class ChatChannel < ApplicationCable::Channel
def receive(data)
if rate_limited?
Rails.logger.warn("Rate limit exceeded by user #{current_user.id}")
return
end
# ...create message and broadcast
end
end
6.3 Message Ordering & Deduplication
One article stresses that WebSockets don’t guarantee message ordering or delivery. fileciteturn0file1turn0file2
A simple pattern:
- Add a monotonic sequence per room or stream.
- Include
sequencein the payload. - Clients discard old or duplicate sequence numbers.
Migration:
class AddSequenceToMessages < ActiveRecord::Migration[7.1]
def change
add_column :messages, :sequence, :bigint, null: false, default: 0
add_index :messages, [:room_id, :sequence], unique: true
end
end
Assign sequence:
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :room
belongs_to :user
before_create :assign_sequence
private
def assign_sequence
self.sequence = (room.messages.maximum(:sequence) || 0) + 1
end
end
Broadcast:
ChatChannel.broadcast_to(
@room,
{
id: message.id,
sequence: message.sequence,
content: message.content,
user_id: current_user.id
}
)
Client side, you can store lastSequence per room and ignore messages with sequence <= lastSequence.
6.4 Horizontal Scaling with Redis & Sticky Sessions
The articles talk about scaling using pub/sub, sticky sessions, and load balancing. fileciteturn0file2
config/cable.yml
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") %>
channel_prefix: myapp_production
This uses Redis to propagate messages across all Rails instances, matching the pub/sub architectural pattern recommended in the best-practices article. fileciteturn0file2
At the load balancer:
- Make sure WebSocket upgrade is supported.
- Enable stickiness so a given client reconnects to the same instance (or at least for the duration of a session), following the sticky-session pattern described in the article. fileciteturn0file2
Example snippet for Nginx (simplified):
location /cable {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_pass http://rails_upstream;
}
Stickiness is usually configured at the reverse proxy / load balancer level (NGINX upstream hash, AWS ALB target group stickiness, etc.).
6.5 Monitoring & Metrics
Operational best practices in the articles include monitoring, autoscaling, and alerts. fileciteturn0file2
In Rails you can log:
- Connected users
- Active subscriptions
- Broadcast frequency
- Error rates / disconnect reasons
Example:
# app/channels/application_cable/connection.rb
def connect
super
Rails.logger.info("[ActionCable] Connect user=#{current_user.id}")
end
def disconnect(reason)
Rails.logger.info("[ActionCable] Disconnect user=#{current_user&.id} reason=#{reason}")
end
Hook these logs into something like Loki, Datadog, New Relic, etc., and build dashboards like “active connections”, “messages per second”, etc., as suggested by the reliability article. fileciteturn0file1turn0file2
7. Sharding & Scalability Patterns (with Rails Examples)
The best-practices article goes deep into sharding, sticky sessions, and pub/sub as ways to scale. fileciteturn0file2 Here are some concrete patterns for Rails.
7.1 Simple Horizontal Scaling (Single Region)
For many apps you can start with:
- Multiple Rails web instances (Puma/Unicorn)
- Shared Redis for ActionCable
- One regional load balancer with stickiness
Diagram in words:
Browser → Load Balancer → [Rails + ActionCable + Redis]
All channels are available from all instances because they share Redis.
This can take you surprisingly far before you need more exotic sharding.
7.2 Tenant / Region Sharding
Once you serve users in multiple regions (e.g. EU + US), latency becomes a concern. The article describes this kind of challenge as WebSocket connections are stateful and global distribution is hard. fileciteturn0file2
You can shard by region or tenant:
wss://eu.example.com/cable→ EU cluster (Rails + Redis in EU)wss://us.example.com/cable→ US cluster (Rails + Redis in US)
Routing strategy (very simplified JS):
function websocketHostForUser(user) {
if (user.region === "eu") return "wss://eu.example.com/cable";
return "wss://us.example.com/cable";
}
Each cluster only holds WebSockets for its tenants. Cross-region communication can then happen via:
- Async jobs / message bus
- Periodic synchronization of data
This matches the “shard by namespace” idea in the article: each shard owns part of the client namespace. fileciteturn0file2
7.3 Channel / Topic Sharding
Another approach is to shard by channel/topic type.
Example:
- Cluster A: all chat channels
- Cluster B: all analytics/metrics channels
- Cluster C: all IoT/device channels
Routing example (pseudocode):
function cableUrlForChannel(channelName) {
if (channelName.endsWith("ChatChannel")) {
return "wss://chat.example.com/cable";
} else if (channelName.endsWith("MetricsChannel")) {
return "wss://metrics.example.com/cable";
}
return "wss://realtime.example.com/cable";
}
This reduces the N² problem for some workloads because each cluster sees a smaller subset of traffic.
7.4 N² Problem in Plain Numbers
From the article: as client count grows, potential interactions grow roughly with N². fileciteturn0file2
- 100 users in a room → up to ~10,000 directed message paths
- 1,000 users → up to 1,000,000 paths
If every message potentially fans out to many clients, traffic explodes.
Mitigation techniques:
- Aggregation: instead of sending 1000 individual “👍” reactions, send one
{ emoji: "👍", count: 1000 }. fileciteturn0file2 - Batching: send updates in periodic batches (every 100 ms) instead of instantly one-by-one.
- Scoped rooms: split giant rooms into smaller subrooms (e.g., per topic, per segment).
Rails implementation example for batched updates (pseudo):
# app/services/metrics_buffer.rb
class MetricsBuffer
def self.buffer(metric)
redis.lpush("metrics_buffer", metric.to_json)
end
def self.flush!
items = redis.lrange("metrics_buffer", 0, -1)
redis.del("metrics_buffer")
data = items.map { |j| JSON.parse(j) }
ActionCable.server.broadcast("admin_metrics", { type: "batch", data: data })
end
def self.redis
@redis ||= Redis.new(url: ENV["REDIS_URL"])
end
end
Run MetricsBuffer.flush! every 200ms in a background process or job.
8. Beyond Chat: WebSocket Use Cases in Rails
The first article lists typical WebSocket use cases, many of which are perfect fits for Rails. fileciteturn0file0 Let’s expand the list with practical examples.
8.1 Realtime Notifications
Channel:
# app/channels/notifications_channel.rb
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end
Broadcast from anywhere:
NotificationsChannel.broadcast_to(
user,
{ type: "notification", text: "Your report is ready." }
)
Use this for:
- In-app alerts
- “Toast” messages
- System announcements
8.2 Realtime Dashboards (Admin / Ops)
Channel:
# app/channels/admin_metrics_channel.rb
class AdminMetricsChannel < ApplicationCable::Channel
def subscribed
reject_unauthorized unless current_user.admin?
stream_from "admin_metrics"
end
private
def reject_unauthorized
reject
end
end
Periodic job:
# app/jobs/broadcast_metrics_job.rb
class BroadcastMetricsJob < ApplicationJob
queue_as :default
def perform
stats = {
users_online: User.online.count,
jobs_pending: JobQueue.pending.count,
# ...
}
ActionCable.server.broadcast("admin_metrics", stats)
end
end
Schedule every N seconds with Sidekiq/GoodJob/solid queue.
8.3 Collaborative Editing / Presence
Channel:
# app/channels/document_channel.rb
class DocumentChannel < ApplicationCable::Channel
def subscribed
@document = Document.find(params[:id])
stream_for @document
# Presence event (join)
DocumentChannel.broadcast_to(@document,
{ type: "presence", user_id: current_user.id, event: "joined" }
)
end
def receive(data)
case data["type"]
when "cursor"
DocumentChannel.broadcast_to(@document,
{
type: "cursor",
user_id: current_user.id,
position: data["position"]
}
)
when "patch"
# Apply patch, persist, broadcast diff, etc.
end
end
def unsubscribed
DocumentChannel.broadcast_to(@document,
{ type: "presence", user_id: current_user.id, event: "left" }
)
end
end
Clients can show:
- Who’s editing
- Cursor positions
- Live edits
8.4 IoT / Device Streaming
Rails can be the backend that:
- Accepts device data via HTTP or MQTT → writes to Redis/Postgres
- Streams the latest values to dashboards via WebSockets
Channel:
class DeviceChannel < ApplicationCable::Channel
def subscribed
@device = Device.find_by!(public_id: params[:device_id])
authorize! :read, @device
stream_for @device
end
end
When device data comes in:
DeviceChannel.broadcast_to(
device,
{ type: "reading", temperature: 21.5, humidity: 0.6 }
)
Matches the IoT / telemetry use case mentioned in the article. fileciteturn0file0
8.5 Long-running Jobs / Progress Bars
Instead of polling an endpoint every 2 seconds:
- Client submits job via HTTP → gets
job_id - Subscribes to
JobChannelwith that ID - Job broadcasts progress updates
Channel:
class JobChannel < ApplicationCable::Channel
def subscribed
@job_id = params[:job_id]
stream_from "job_#{@job_id}"
end
end
Job:
class ExportReportJob < ApplicationJob
def perform(user_id, job_id)
10.times do |i|
# Some work...
ActionCable.server.broadcast(
"job_#{job_id}",
{ status: "progress", step: i + 1, total: 10 }
)
end
ActionCable.server.broadcast(
"job_#{job_id}",
{ status: "done", download_url: "/reports/#{job_id}.csv" }
)
end
end
8.6 Live Sports & Market Feeds
- Live match scores, game clock, and commentary
- Betting odds updates
- Stock price and order book updates
Rails can ingest upstream feeds (Kafka, external APIs) and push to clients via ActionCable channels, applying the reliability patterns above to prevent overload. fileciteturn0file0turn0file1
8.7 Live Auctions & Bidding
- Users see bids appear in realtime.
- Auction countdown timers synchronize across all clients.
- Anti-sniping strategies can be applied server-side and updates pushed instantly.
Channel example:
class AuctionChannel < ApplicationCable::Channel
def subscribed
@auction = Auction.find(params[:id])
stream_for @auction
end
def place_bid(data)
amount = data["amount"].to_d
@auction.place_bid!(current_user, amount)
AuctionChannel.broadcast_to(@auction, {
type: "bid_placed",
user_id: current_user.id,
amount: amount.to_s
})
end
end
8.8 Realtime Education & Collaboration
- Live quizzes (questions & answers in realtime)
- Shared whiteboards (drawing events over WebSockets)
- Pair programming or coding interviews
You can model each classroom/session as a channel where:
- Teacher broadcasts questions, slides, or code snippets.
- Students send answers or reactions.
- “Raise hand” events show up for the instructor instantly.
9. When to Build vs When to Use a Managed WebSocket Service
The articles make a good point: running your own WebSocket infra at scale is expensive and complex (time, cost, global distribution, reliability). fileciteturn0file1turn0file2
Rails + ActionCable is great when:
- You control your user count / traffic
- You’re okay operating your own Redis + WebSocket infra
- Most traffic is within a few regions
If you need:
- Millions of concurrent connections
- Global edge presence
- Stronger delivery guarantees (exactly-once, ordering, persistence)
- Built-in fallbacks across protocols
…then a managed realtime platform (like Ably, Pusher, etc.) sitting beside your Rails API becomes attractive, as the articles argue. fileciteturn0file1turn0file2
10. Conclusion
Bringing it all together:
- WebSockets give us low-latency, bidirectional realtime communication — perfect for far more than just chat. fileciteturn0file0
- The articles remind us that reliability, message guarantees, and scaling are not solved by the protocol alone. fileciteturn0file1turn0file2
- Rails + ActionCable + Redis + Devise + JWT provide a strong foundation:
- JWT auth at the WebSocket handshake
- Redis-based pub/sub for horizontal scaling
- Channels for modeling realtime domains (chat, notifications, dashboards, IoT, collaboration, auctions, education, and more)
- By layering:
- Rate limiting / backpressure
- Sequence numbers and dedup
- Monitoring and metrics
- Sticky sessions or sharding strategies in the load balancer
…you can build production-grade realtime APIs in Rails that align with the WebSocket best practices described in the articles.
Drop this file into your Jekyll blog’s _posts (renaming it with the proper date + slug), tweak the title/metadata, and you’ve got a comprehensive, code-heavy guide to WebSockets with Rails ready to publish.