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
  • 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.

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.


1.2 The Less Glamorous Bits: Reliability & Scaling

Out of the box, WebSockets don’t give you:

  • 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:

  • 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.

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.
  • Pub/Sub – A messaging pattern (and often a dedicated system) for fanning messages out to many consumers.

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:

  1. Browsers connect to your backend over WebSockets.
  2. Each backend node connects to a pub/sub layer (Redis, Kafka, Ably, etc.).
  3. 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.


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.).

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.


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.

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”.

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.

A simple pattern:

  1. Add a monotonic sequence per room or stream.
  2. Include sequence in the payload.
  3. 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.

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.

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.

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.

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.


7. Sharding & Scalability Patterns (with Rails Examples)

The best-practices article goes deep into sharding, sticky sessions, and pub/sub as ways to scale. 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.

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.


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 .

  • 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 }.
  • 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. 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.


8.5 Long-running Jobs / Progress Bars

Instead of polling an endpoint every 2 seconds:

  1. Client submits job via HTTP → gets job_id
  2. Subscribes to JobChannel with that ID
  3. 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.


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).

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.


10. Conclusion

Bringing it all together:

  1. WebSockets give us low-latency, bidirectional realtime communication — perfect for far more than just chat.
  2. The articles remind us that reliability, message guarantees, and scaling are not solved by the protocol alone.
  3. 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)
  4. 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.