Real-Time Ruby: ActionCable in Rails 7.2 — Cover Art

Real-Time Ruby

ActionCable in Rails 7.2 — WebSockets, C++ Workers, and Live Dashboards
Thurston Matthews
This paper is part of my study into using AI to generate science and engineering papers.

Table of Contents

  1. What Is ActionCable?
  2. Architecture: How ActionCable Works Internally
    1. The Big Picture
    2. WebSocket Connection Lifecycle
    3. Channels and Subscriptions
    4. Adapter Layer: Online vs. Redis
  3. Rails 7.2 Specifics
    1. Import Maps and Esbuild Integration
    2. Authenticated Connections
  4. Tutorial: Adding ActionCable to an Existing Rails App
    1. Setup and Configuration
    2. Server-Side: Connection, Channel, and Worker
    3. The C++ Number-Crunching Backend
    4. Client-Side: JavaScript Consumer
    5. Redis Pub/Sub Bridge
    6. Wiring It All Together
  5. C++ as an ActionCable Client
  6. Production Patterns and Pitfalls
  7. Summary

1. What Is ActionCable?

ActionCable is Rails' built-in framework for real-time, bidirectional communication between a browser and a Rails server over WebSockets. It was introduced in Rails 5.0 and has been refined through every release since. In Rails 7.2, ActionCable integrates seamlessly with modern asset pipelines (Import Maps, Esbuild, or Propshaft), supports connection streaming for fan-out scenarios, and works cleanly with the new config.ru boot process.

Unlike HTTP — which requires the client to poll for updates — WebSockets keep a single TCP connection open. The server can push data to the client at any time. This makes ActionCable ideal for:

ActionCable provides a convention-over-configuration structure:

Key Insight ActionCable is not just a WebSocket wrapper. It adds Rails conventions: named channels, subscription management, broadcast adapters, automatic reconnection, and seamless integration with the Rails authentication stack.

2. Architecture: How ActionCable Works Internally

2.1 The Big Picture

When a user opens a page that uses ActionCable, the following components interact:

ActionCable Architecture: User, Web Browser, Rails App, Redis Pub/Sub, and C++ Worker showing WebSocket connections, channel subscriptions, real-time updates, and bidirectional communication
Figure 1 — ActionCable architecture with external C++ worker communicating through Redis Pub/Sub

The flow works in four layers:

  1. Transport layer — A WebSocket connection is established at /cable. The browser's JavaScript consumer maintains this connection.
  2. Connection layerApplicationCable::Connection authenticates the connection. If it calls reject_unauthorized_connection, the handshake fails. Otherwise, instance variables set here become available to all channels.
  3. Channel layer — The client subscribes to named channels. Each channel has a subscribed callback (entry), a received callback (client → server data), and can stream_from broadcast queues.
  4. Adapter layer — Broadcasts go through an adapter. The inline (or async) adapter delivers in-process. The redis adapter uses Redis Pub/Sub so multiple Rails processes and external clients (like C++ programs) can participate.

2.2 WebSocket Connection Lifecycle

The WebSocket handshake follows the RFC 6455 protocol:

  1. The browser sends an HTTP GET /cable with Upgrade: websocket and Sec-WebSocket-Key headers.
  2. Rails' middleware (via the actioncable gem, mounted in the Rails router) intercepts this request.
  3. The Connection class runs. It has access to cookies, request, and env — the same as a controller — so you can authenticate using cookies.signed[:user_id], JWT tokens, or session data.
  4. If authentication succeeds, Rails responds 101 Switching Protocols and the TCP connection is upgraded to a full-duplex WebSocket.
  5. The client then sends subscription commands as JSON frames. The server responds with confirmation or rejection.

If the WebSocket drops, the JavaScript client automatically attempts reconnection with exponential backoff. The connection is re-authenticated on each reconnection attempt.

2.3 Channels and Subscriptions

Channels are the logical grouping mechanism in ActionCable. Think of them as named topics. A client can subscribe to multiple channels simultaneously:

// Client subscribes to a channel with parameters
App.cable.subscriptions.create(
  { channel: "ComputationChannel", job_id: "abc123" },
  {
    received(data) { console.log(data.progress); },
    start() { this.perform("start"); },
  }
);

On the server side, the channel class handles the subscription:

class ComputationChannel < ApplicationCable::Channel
  def subscribed
    stream_from "computation_#{params[:job_id]}"
  end

  def unsubscribed
    # stop any background work tied to this subscription
  end

  def start
    # spawn the C++ worker; it will publish to the broadcast queue
    ComputationJob.perform_async(id: params[:job_id])
  end
end

The stream_from call tells ActionCable: "whenever someone broadcasts to this queue name, forward the message to this subscriber." Multiple subscribers can stream_from the same queue, enabling one-to-many fan-out.

2.4 Adapter Layer: Online vs. Redis

ActionCable abstracts the broadcast delivery mechanism behind an adapter interface. The choice of adapter determines whether broadcasts stay in-process or go through an external message broker:

Adapter Delivery Use Case External Clients?
inline Synchronous, same thread Testing No
async Background thread (Ruby queue) Single-process development No
solid_cable Database-polling (PostgreSQL/MySQL) No-Redis deployments No
redis Redis Pub/Sub Production, multi-process Yes
postcastle PostgreSQL LISTEN/NOTIFY PostgreSQL-only stacks Limited

For our tutorial, we use the Redis adapter — because it's the only adapter that lets an external C++ program publish messages that Rails will forward to connected browsers.

Important If you're running multiple Puma workers (which is the production default), broadcasts from one worker cannot reach subscribers in another worker unless you use Redis (or Solid Cable). The inline adapter only works within a single Ruby process.

3. Rails 7.2 Specifics

Rails 7.2 brings several refinements to ActionCable:

3.1 Import Maps and Esbuild Integration

In Rails 7.x, you have three asset pipeline options for the JavaScript side of ActionCable:

With Import Maps (the default), you add ActionCable to config/importmap.rb:

pin "@rails/actioncable", to: "actioncable.esm.js"
pin "channels", to: "channels/index.js"

Then in app/javascript/application.js:

import "@rails/actioncable"
import "./channels"

With Esbuild, install via npm:

bin/dev
# In another terminal:
npm install @rails/actioncable

3.2 Authenticated Connections

Rails 7.2's connection handler has full access to the signed cookie jar. The recommended pattern for authenticated WebSocket connections:

class ApplicationCable::Connection < ActionCable::Connection::Base
  identified_by :current_user

  def connect
    self.current_user = find_verified_user
  end

  private

  def find_verified_user
    if verified_user = User.find_by(id: cookies.signed[:user_id])
      verified_user
    else
      reject_unauthorized_connection
    end
  end
end

The identified_by declaration creates an identifier that channels can reference. In any channel:

def subscribed
  return reject unless current_user # available from Connection
  stream_from "user_#{current_user.id}_notifications"
end

4. Tutorial: Adding ActionCable to an Existing Rails App

In this tutorial, we'll build a complete real-time system:

Prerequisites Ruby 3.2+, Rails 7.2, Redis running on localhost:6379, g++ 11+, and a C++17-capable compiler. For the C++ Redis client, we'll use the hiredis library.
1

Setup and Configuration

Generate the ActionCable scaffold

rails new computation_dashboard --css=tailwind
cd computation_dashboard

# ActionCable files are generated by default in Rails 7.2,
# but let's verify they exist:
ls app/channels/application_cable/
# connection.rb
# channel.rb

Configure the Redis adapter

# config/cable.yml
development:
  adapter: redis
  url: redis://localhost:6379/1

production:
  adapter: redis
  url: <%= ENV["REDIS_URL"] %>
  channel_prefix: computation_dashboard_production

Add the Redis gem

# Gemfile
gem "redis", "~> 5.0"

bundle install
2

Server-Side: Connection, Channel, and Worker

Connection authentication

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    # For this tutorial we allow open connections.
    # In production, authenticate as shown in Section 3.2.
    def connect
      # self.current_user = find_verified_user
      logger.add_tags "ActionCable", "RemoteAddr: #{request.remote_ip}"
    end
  end
end

The Computation Channel

# app/channels/computation_channel.rb
class ComputationChannel < ApplicationCable::Channel
  def subscribed
    # Stream from a Redis channel named "computation:#{params[:job_id]}"
    stream_from "computation:#{params[:job_id]}"

    # If the client asks us to start on subscription:
    if params[:start] == "true"
      ComputationWorker.spawn(params[:job_id])
    end
  end

  def unsubscribed
    # cleanup if needed
  end

  def start(data)
    job_id = data["job_id"] || SecureRandom.hex(8)
    ComputationWorker.spawn(job_id)
    transmit(status: "started", job_id: job_id)
  end
end

The Worker Spawner

# app/workers/computation_worker.rb
class ComputationWorker
  REDIS_BIN = "/usr/local/bin/redis-cli"

  def self.spawn(job_id)
    # Launch the C++ binary in the background.
    # It connects to Redis and publishes progress updates.
    binary = Rails.root.join("bin", "compute_pi").to_s

    unless File.exist?(binary)
      $stderr.puts "ERROR: #{binary} not found. Run 'make' first."
      return
    end

    pid = Process.spawn(
      binary,
      job_id.to_s,
      err: Rails.root.join("log", "compute_stderr.log"),
      out: Rails.root.join("log", "compute_stdout.log")
    )

    Rails.logger.info "Spawned C++ worker PID=#{pid} for job #{job_id}"
  end
end
3

The C++ Number-Crunching Backend

Our C++ program will compute π using the Monte Carlo method. It connects to Redis using the hiredis library and publishes progress updates at regular intervals.

Build the hiredis dependency

# Install hiredis (on Debian/Ubuntu):
sudo apt-get install libhiredis-dev

# Or build from source:
git clone https://github.com/redis/hiredis.git
cd hiredis && make && sudo make install

The C++ program

// src/compute_pi.cpp
#include <iostream>
#include <random>
#include <string>
#include <chrono>
#include <thread>
#include <cstring>
#include <hiredis/hiredis.h>
#include <json/json.h> // or use nlohmann/json

// Simple JSON builder to avoid heavy dependencies
std::string make_json(const std::string &status, int iterations,
                      double estimate, double elapsed) {
  return "{\"status\":\"" + status +
         "\",\"iterations\":" + std::to_string(iterations) +
         ",\"estimate\":" + std::to_string(estimate) +
         ",\"elapsed\":" + std::to_string(elapsed) + "}";
}

int main(int argc, char* argv[]) {
  if (argc != 2) {
    std::cerr << "Usage: " << argv[0] << " <job_id>" << std::endl;
    return 1;
  }
  std::string job_id = argv[1];
  std::string redis_channel = "computation:" + job_id;

  // Connect to Redis
  redisContext* ctx = redisConnect("127.0.0.1", 6379);
  if (ctx == nullptr || ctx->err) {
    std::cerr << "Cannot connect to Redis: "
              << (ctx ? ctx->errstr : "out of memory") << std::endl;
    if (ctx) redisFree(ctx);
    return 1;
  }
  std::cout << "Connected to Redis. Publishing to "
            << redis_channel << std::endl;

  // Monte Carlo pi estimation
  const int total_batches = 100;
  const int batch_size = 1000000; // 1 million samples per batch
  std::mt19937_64 rng(42);
  std::uniform_real_distribution<double> dist(0.0, 1.0);

  auto start_time = std::chrono::steady_clock::now();
  int inside_circle = 0;

  for (int batch = 0; batch < total_batches; ++batch) {
    for (int i = 0; i < batch_size; ++i) {
      double x = dist(rng);
      double y = dist(rng);
      if (x * x + y * y <= 1.0) {
        inside_circle++;
      }
    }

    auto now = std::chrono::steady_clock::now();
    double elapsed = std::chrono::duration<double>(now - start_time).count();
    int total_samples = (batch + 1) * batch_size;
    double pi_estimate = 4.0 * inside_circle / total_samples;
    int progress_pct = (batch + 1) * 100 / total_batches;

    // Publish progress to Redis
    std::string payload = make_json(
      "progress",
      total_samples,
      pi_estimate,
      elapsed
    );

    redisReply* reply = (redisReply*)redisCommand(ctx,
      "PUBLISH %s %s", redis_channel.c_str(), payload.c_str());
    if (reply) freeReplyObject(reply);

    std::cout << "Batch " << (batch + 1) << "/" << total_batches
              << " — π ≈ " << pi_estimate
              << " (" << progress_pct << "%)" << std::endl;

    // Small delay so the browser can render updates
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
  }

  // Publish final result
  auto end_time = std::chrono::steady_clock::now();
  double total_elapsed = std::chrono::duration<double>(end_time - start_time).count();
  double final_estimate = 4.0 * inside_circle / (total_batches * batch_size);

  std::string final_payload = make_json(
    "complete",
    total_batches * batch_size,
    final_estimate,
    total_elapsed
  );

  redisReply* reply = (redisReply*)redisCommand(ctx,
    "PUBLISH %s %s", redis_channel.c_str(), final_payload.c_str());
  if (reply) freeReplyObject(reply);

  std::cout << "Done! Final π ≈ " << final_estimate
            << " in " << total_elapsed << "s" << std::endl;

  redisFree(ctx);
  return 0;
}

Makefile

# Makefile
CXX = g++
CXXFLAGS = -std=c++17 -O2 -pthread
LDFLAGS = -lhiredis

bin/compute_pi: src/compute_pi.cpp
  @mkdir -p bin
  $(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS)

clean:
  rm -f bin/compute_pi
make
# Builds bin/compute_pi
4

Client-Side: JavaScript Consumer

Configure the ActionCable consumer

// app/javascript/channels/consumer.js
import { createConsumer } from "@rails/actioncable"

export default createConsumer()
// app/javascript/channels/index.js
import consumer from "./consumer"
import ComputationChannel from "./computation_channel"

// Register the channel
consumer.subscriptions.create(
  { channel: "ComputationChannel", start: "true", job_id: "default" },
  ComputationChannel
)
// app/javascript/channels/computation_channel.js
export default {
  connected() {
    console.log("Connected to ComputationChannel");
    this.updateUI("connected", "Waiting for computation to start...");
  },

  disconnected() {
    this.updateUI("disconnected", "Disconnected from server");
  },

  received(data) {
    console.log("Received:", data);
    if (data.status === "started") {
      this.updateUI("running", `Job ${data.job_id} started`);
    } else if (data.status === "progress") {
      this.updateProgress(data);
    } else if (data.status === "complete") {
      this.updateComplete(data);
    }
  },

  // ---- UI update helpers ----
  updateUI(state, message) {
    const el = document.getElementById("status");
    if (el) {
      el.className = `status ${state}`;
      el.textContent = message;
    }
  },

  updateProgress(data) {
    const progressEl = document.getElementById("progress-bar");
    const estimateEl = document.getElementById("pi-estimate");
    const elapsedEl = document.getElementById("elapsed");
    const samplesEl = document.getElementById("samples");

    if (progressEl) {
      const pct = Math.round((data.iterations / 100000000) * 100);
      progressEl.style.width = `${pct}%`;
      progressEl.textContent = `${pct}%`;
    }
    if (estimateEl) estimateEl.textContent = data.estimate.toFixed(8);
    if (elapsedEl) elapsedEl.textContent = `${data.elapsed.toFixed(1)}s`;
    if (samplesEl) samplesEl.textContent = data.iterations.toLocaleString();
  },

  updateComplete(data) {
    this.updateUI("complete", `Computation finished!`);
    const progressEl = document.getElementById("progress-bar");
    if (progressEl) {
      progressEl.style.width = "100%";
      progressEl.textContent = "100%";
    }
    this.updateProgress(data);
  },
};

The HTML view

<%# app/views/computations/show.html.erb %>
<% content_for :title, "Live Computation Dashboard" %>

<div class="dashboard">
  <h1>Monte Carlo π Estimation</h1>

  <div id="status" class="status" style="padding:12px; margin-bottom:20px; border-radius:6px; background:#f0f0f0;">
    Connecting...
  </div>

  <div class="progress-container" style="background:#e0e0e0; border-radius:8px; height:32px; margin-bottom:24px; overflow:hidden;">
    <div id="progress-bar" style="width:0%; height:100%; background:linear-gradient(90deg, #cc342d, #f58220); color:#fff; text-align:center; line-height:32px; font-weight:bold; transition:width 0.3s;">
      0%
    </div>
  </div>

  <div class="metrics" style="display:grid; grid-template-columns:repeat(3,1fr); gap:16px;">
    <div style="background:#fff; padding:20px; border-radius:8px; border:1px solid #e0e0e0; text-align:center;">
      <div style="font-size:0.9em; color:#666;">π Estimate</div>
      <div id="pi-estimate" style="font-size:2em; font-weight:bold; color:#cc342d;">—</div>
    </div>
    <div style="background:#fff; padding:20px; border-radius:8px; border:1px solid #e0e0e0; text-align:center;">
      <div style="font-size:0.9em; color:#666;">Samples</div>
      <div id="samples" style="font-size:1.6em; font-weight:bold;">0</div>
    </div>
    <div style="background:#fff; padding:20px; border-radius:8px; border:1px solid #e0e0e0; text-align:center;">
      <div style="font-size:0.9em; color:#666;">Elapsed</div>
      <div id="elapsed" style="font-size:1.6em; font-weight:bold;">0.0s</div>
    </div>
  </div>
</div>
5

Redis Pub/Sub Bridge

Here's how the Redis adapter makes the C++ → Rails → Browser bridge work:

  1. C++ publishes — The C++ program calls redisCommand(ctx, "PUBLISH computation:abc123 {...}"). This sends a message to the Redis channel computation:abc123.
  2. Rails subscribes — When the JavaScript client subscribed to ComputationChannel with job_id: "abc123", the channel called stream_from "computation:abc123". ActionCable's Redis adapter issued a SUBSCRIBE computation:abc123 to Redis.
  3. Redis delivers — Redis routes the published message to all subscribers of that channel, including Rails' Redis adapter subscriber thread.
  4. Rails forwards — The adapter receives the message and ActionCable pushes it through the WebSocket connection to the browser.
  5. JavaScript receives — The received(data) callback fires, and the UI updates.

The key insight: the C++ program never talks to the browser directly. It publishes to Redis, and Rails acts as the relay. This means:

6

Wiring It All Together

Routes

# config/routes.rb
Rails.application.routes.draw do
  root "computations#show"
end

Controller

# app/controllers/computations_controller.rb
class ComputationsController < ApplicationController
  def show
    # The page just loads — ActionCable handles the rest
  end
end

Start everything up

# Terminal 1 — Redis
redis-server --daemonize yes

# Terminal 2 — Rails server
rails server

# Terminal 3 — Build the C++ binary
make

# Now open http://localhost:3000
# The C++ worker starts automatically via the channel subscription
Testing without C++ You can test the ActionCable pipeline manually from the Rails console:
ComputationChannel.broadcast_to(
  "default",
  { status: "progress", iterations: 5000000, estimate: 3.14159, elapsed: 2.5 }
)
This broadcasts directly to the WebSocket-connected browser without needing the C++ worker.

5. C++ as an ActionCable Client

So far we've used C++ as a publisher — it sends data to Redis, and Rails relays it. But what if C++ needs to receive commands from the browser too? For that, C++ can connect directly to the ActionCable WebSocket endpoint.

ActionCable uses a custom message format over WebSocket. Commands are JSON objects with a command key:

Command Purpose Example
subscribe Subscribe to a channel {"command":"subscribe","identifier":"{\"channel\":\"ComputationChannel\",\"job_id\":\"x\"}","callback":"message"}
perform Call an action on a channel {"command":"perform","identifier":"{...}","data":"{\"action\":\"start\"}"}
unsubscribe Leave a channel {"command":"unsubscribe","identifier":"{...}"}
ping Keep-alive {"command":"ping","message":"hello"}

Responses from the server come as JSON with a type key:

Type Meaning
confirm_subscription Channel subscription accepted
reject_subscription Channel subscription denied
message Data from a broadcast or transmit call
ping Server keep-alive (reply with pong)
disconnect Server closing the connection

Complete C++ WebSocket client

Using the websocketpp library (header-only, no compilation needed):

// src/cpp_cable_client.cpp
#include <iostream>
#include <string>
#include <websocketpp/config/asio_client.hpp>
#include <websocketpp/client.hpp>
#include <nlohmann/json.hpp>

using json = nlohmann::json;
using client = websocketpp::client<websocketpp::config::asio_client>;

class ActionCableClient {
  client m_ws;
  websocketpp::lib::shared_ptr<const client::connection> m_conn;
  std::string m_job_id;

public:
  ActionCableClient(const std::string &job_id)
    : m_job_id(job_id) {
    m_ws.init_asio();
    m_ws.set_access_channels(websocketpp::log::alevel::none);
    m_ws.set_error_channels(websocketpp::log::elevel::rerror);
  }

  void connect(const std::string &url = "ws://localhost:3000/cable") {
    m_ws.set_message_handler(
      bind(&ActionCableClient::on_message, this,
           websocketpp::lib::placeholders::_1,
           websocketpp::lib::placeholders::_2)
    );

    websocketpp::lib::error_code ec;
    m_conn = m_ws.get_connection(url, ec);
    m_ws.connect(m_conn);
    m_ws.run();
  }

private:
  void on_message(
    client* c,
    websocketpp::connection_handle,
    const client::message_ptr& msg)
  {
    try {
      json response = json::parse(msg->get_payload());
      std::string type = response.value("type", "");

      if (type == "confirm_subscription") {
        std::cout << "Subscribed to channel!" << std::endl;
        // Now start computing and publish via Redis
        run_computation();
      } else if (type == "message") {
        json data = response.value("data", json::object());
        std::string action = data.value("action", "");
        if (action == "start") {
          std::cout << "Browser said: start computing!" << std::endl;
          run_computation();
        }
      } else if (type == "ping") {
        // Respond with pong
        json pong = {{"command", "pong"}};
        c->send(pong.dump(), websocketpp::frame::opcode::text);
      }
    } catch (const std::exception &e) {
      std::cerr << "Parse error: " << e.what() << std::endl;
    }
  }

  void subscribe() {
    json identifier = {
      {"channel", "ComputationChannel"},
      {"job_id", m_job_id}
    };
    json cmd = {
      {"command", "subscribe"},
      {"identifier", identifier.dump()},
      {"callback", "message"}
    };
    m_conn->send(cmd.dump(), websocketpp::frame::opcode::text);
  }

  void perform_action(const std::string &action) {
    json identifier = {
      {"channel", "ComputationChannel"},
      {"job_id", m_job_id}
    };
    json data = {{"action", action}};
    json cmd = {
      {"command", "perform"},
      {"identifier", identifier.dump()},
      {"data", data.dump()}
    };
    m_conn->send(cmd.dump(), websocketpp::frame::opcode::text);
  }

  void run_computation() {
    // Same Monte Carlo computation as before,
    // publishing progress to Redis
    std::cout << "Starting computation..." << std::endl;
    // ... (Monte Carlo code here)
  }
};

int main() {
  std::string job_id = "cpp-direct";
  ActionCableClient client(job_id);
  client.connect("ws://localhost:3000/cable");
  return 0;
}

This C++ client:

  1. Opens a WebSocket to ws://localhost:3000/cable
  2. Subscribes to ComputationChannel using ActionCable's protocol
  3. Receives confirm_subscription from Rails
  4. Can receive perform actions from the browser via the channel
  5. Publishes computation results back via Redis (as before) — Rails broadcasts to the browser

This creates a three-way communication pattern:

Production note Running C++ as a direct ActionCable client means it holds a WebSocket connection slot. In high-concurrency scenarios, prefer the Redis-only pattern (Section 4) because Redis Pub/Sub is more scalable than maintaining thousands of WebSocket connections from backend workers.

6. Production Patterns and Pitfalls

Connection Limits

Puma's default thread pool and Puma workers determine how many WebSocket connections your Rails app can handle. Each WebSocket connection holds a thread while idle. Calculate:

max_connections = puma_workers × puma_threads

If you expect more simultaneous WebSocket connections than threads, you'll need to increase Puma's thread count or consider a dedicated ActionCable server process.

Stale Subscriptions

When a browser tab is closed, the WebSocket closes and ActionCable cleans up subscriptions. However, if the network drops without a proper close (e.g., laptop sleep), the connection may stay open until the TCP timeout. Use ActionCable's ping/pong mechanism (built-in) to detect dead connections:

# config/cable.yml — defaults are fine:
development:
  adapter: redis
  # ping_interval: 60 (seconds between pings)
  # max_wait_time: 300 (seconds before declaring a connection dead)

Broadcast Buffering

If a client's WebSocket send buffer fills up (e.g., the user's connection is very slow), ActionCable will eventually drop that subscriber from the broadcast queue. This prevents slow clients from blocking the entire system. Fast clients are unaffected.

Security

Never trust the client. ActionCable channels should validate every received message. A malicious client can send arbitrary JSON through the WebSocket:

class ComputationChannel < ApplicationCable::Channel
  def received(data)
    # Validate before acting
    action = data["action"]
    case action
    when "cancel"
      cancel_computation if authorized?
    when "restart"
      # Rate-limit this action
      restart_computation if authorized? && can_restart?
    else
      logger.warn "Unknown action: #{action}"
    end
  end
end

Scaling Beyond a Single Rails Instance

When you deploy to multiple Rails instances behind a load balancer:

7. Summary

ActionCable in Rails 7.2 provides a complete real-time communication stack with minimal configuration. The architecture — Connection → Channel → Adapter — gives you authentication, logical grouping, and flexible broadcast delivery all in one framework.

The key takeaway for integrating non-Ruby backends: use Redis as the universal bus. Your C++, Python, Go, or Rust programs publish to Redis channels. Rails subscribes through ActionCable's Redis adapter and forwards to the browser. This pattern decouples your computation layer from your presentation layer and lets each component be written in the best tool for the job.

For bidirectional control (browser commands reaching C++), C++ can also connect directly to ActionCable's WebSocket endpoint using the standard protocol — subscribe, perform, receive — giving you full two-way communication between any language and your Rails frontend.

Component Technology Role
Browser JavaScript + @rails/actioncable WebSocket client, live UI updates
Rails ActionCable + Redis adapter WebSocket server, authentication, relay
Redis Pub/Sub Message broker between Rails and C++
C++ Worker hiredis Number crunching, publishes progress to Redis
C++ Client (optional) websocketpp + nlohmann/json Direct ActionCable subscriber for bidirectional control