Real-time WebSockets in Rails: ActionCable, Pusher, or Vask

You have a Rails app and you want real-time. A notifications drawer that updates without a page refresh. A presence indicator on a document. A live dashboard. A typing indicator in a chat surface. The pattern fits broadcasts and channels, and the question is which substrate carries them.

The Rails ecosystem gives you three honest options in 2026. ActionCable is the first-party answer, built into the framework since Rails 5. Pusher and Vask are hosted services that speak the Pusher Channels protocol, a different wire format from ActionCable but one with its own set of advantages around edge delivery and bill shape. The decision is about operating model and bill shape, not about rewriting your app.

Get started

Realtime made simple.

Free Tier: 500K broadcasts/mo and 100 concurrent across unlimited apps. $10/mo when you outgrow it.

Option 1: ActionCable (first-party, in-process)

ActionCable is the WebSocket server built into Rails. It runs inside your Puma process, integrates with your existing session and Devise or Warden auth pipeline, and uses Redis or PostgreSQL as a broadcast backplane when you need multiple processes or hosts. No extra gem, no extra service beyond the backplane adapter you may already run.

What it gives you:

  • A first-party, free, open-source WebSocket layer maintained by the Rails core team.
  • Deep session integration: current_user available inside channel callbacks from day one.
  • A backplane adapter model (Redis, PostgreSQL, or async in development) that scales horizontally when you add Puma processes.
  • Hotwire Turbo Streams as a first-class broadcast target if you are on a Turbo-shaped Rails app.

When it is the right call:

  • Low to medium traffic, single-region apps where Puma can hold the WebSocket concurrency without a dedicated process.
  • Teams that want to stay in Ruby end-to-end and avoid adding the JavaScript SDK overhead of pusher-js.
  • Projects on Hotwire where Turbo Broadcasts feed UI fragments directly, without a separate JS subscription layer.
  • Local development for any Rails app, regardless of what runs in production.

When you'd reach for something else:

  • You want connections to terminate at the closest edge city rather than your origin server.
  • The Redis or PostgreSQL backplane adapter is becoming an operational concern at your traffic level.
  • Your broadcast volume has grown such that the backplane is a bottleneck and horizontal scaling has become a project.

If ActionCable fits, use ActionCable. The rest of this page is for the cases where it does not, or for teams already on hosted Pusher evaluating where to go next.

Option 2: Hosted Pusher Channels (the incumbent)

Pusher Channels is the original hosted Pusher-protocol service. The pusher-http-ruby gem handles server-side triggers, pusher-js handles the client, and the integration is well-documented. For years it was the obvious choice for a Rails app that wanted broadcast delivery without running its own WebSocket server.

What it gives you:

  • A fully managed, hosted Pusher-protocol service. Configure credentials, point pusher-js at the right cluster, and it works.
  • A mature SDK ecosystem: pusher-http-ruby on the server, pusher-js on the client, presence channels, private channels, channel auth.
  • No backplane adapter to operate. Pusher handles delivery to all connected clients.

The thing nobody warns you about until you cross a threshold:

The bill scales with the number of subscribers on each channel, not with the number of broadcasts you publish. The category has a name for this workload (fan-out) and the surcharge attached to it is the fan-out tax. One broadcast to a channel with 100 subscribers counts as 101 billable messages on the standard pricing model. A presence channel with 1,000 connected users makes a single broadcast into 1,001 billable messages. Ship a typing indicator over a busy room and the math compounds quickly.

This is not a quirk. It is the priced unit of the product. If your fan-out factor is high or growing, the calculator on /compare/pusher-vs-vask shows what the per-fan-out multiplier is doing to your specific bill.

When it is still a reasonable call:

  • You are already on Pusher, the bill is fine for your traffic shape, and switching costs you migration time. If the math does not justify it, do not switch.
  • Your traffic profile is broadcast-light per subscriber, which the per-message model handles without surprise.

Option 3: Vask (hosted Pusher protocol on Cloudflare's edge)

Vask is the option that did not exist a few years ago and now does. A managed Pusher-protocol WebSocket service running on Cloudflare's edge network, billed per broadcast (not per fan-out copy), with connections terminating at the closest of 330+ edge cities.

What it gives you:

  • The Pusher Channels protocol on the wire. Your pusher-http-ruby triggers, your pusher-js subscriptions, your channel auth endpoint, your presence channel member payloads: all unchanged.
  • Cloudflare's edge as the substrate. Connections terminate on Cloudflare's edge network.
  • Broadcast-priced billing. One broadcast is one broadcast on the bill, regardless of how many subscribers are on the channel. No fan-out tax. No presence-channel surcharge.
  • Drop-in compatibility. If you are already on hosted Pusher, the cutover is a host and credential change in your environment plus a Puma restart.

When it is the right call:

  • You are on hosted Pusher today and the bill is dominated by fan-out. Same protocol, broadcast-priced billing.
  • You want multi-region edge presence without operating a multi-region server fleet.
  • You want hosted Pusher protocol without a backplane adapter to manage and without holding WebSocket connections inside your Puma processes.

When it is not the right call:

  • ActionCable fits, and the answer is ActionCable. (Repeated because it matters.)
  • You are below the free tier on your current service and the bill is zero. Switch when the bill shows up, not before.
  • Your real-time feature does not fit the Pusher Channels protocol.

If you are migrating off hosted Pusher specifically, the step-by-step Rails recipe is at /migrate/pusher-to-vask-rails. The head-to-head comparison with calculator is at /compare/pusher-vs-vask.

What the Rails code actually looks like

The Pusher-protocol path (Pusher and Vask) shares the same application code. The differences are in credentials and the initializer.

Server setup

Gemfile:

gem 'pusher'

config/initializers/pusher.rb:

require 'pusher'

Pusher.app_id  = ENV.fetch('PUSHER_APP_ID')
Pusher.key     = ENV.fetch('PUSHER_APP_KEY')
Pusher.secret  = ENV.fetch('PUSHER_APP_SECRET')
Pusher.cluster = ENV.fetch('PUSHER_APP_CLUSTER', 'mt1')
Pusher.host    = ENV['PUSHER_HOST'] if ENV['PUSHER_HOST'].present?
Pusher.port    = ENV['PUSHER_PORT'].to_i if ENV['PUSHER_PORT'].present?

Set PUSHER_HOST to your Vask endpoint to route through Vask; leave it unset to route through Pusher's cluster.

Triggering a broadcast

From a job or controller action:

Pusher.trigger('orders', 'order.shipped', { order_id: order.id })

That is one broadcast. One billable unit on Vask, regardless of how many subscribers are on orders.

Client setup (pusher-js)

Install via npm (package.json) if you are on an esbuild or importmap-with-bundler setup:

npm install pusher-js

Then import and configure. If you are using importmap, Sprockets, or a CDN script tag, render the public config from Rails instead of reading process.env in the browser. In the example below, window.VASK_PUSHER is a template-rendered object with key, host, and cluster.

import Pusher from 'pusher-js';

const config = window.VASK_PUSHER;

const pusher = new Pusher(config.key, {
    cluster: config.cluster || 'mt1',
    wsHost: config.host || 'wss.vask.dev',
    wsPort: 443,
    wssPort: 443,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
    channelAuthorization: {
        endpoint: '/pusher/auth',
        transport: 'ajax',
        headers: {
            'X-CSRF-Token':
                document.querySelector('meta[name="csrf-token"]')?.content ??
                '',
        },
    },
});

The channelAuthorization.headers block is the Rails-specific addition. Rails CSRF protection rejects the auth POST without it.

Channel auth endpoint

config/routes.rb:

post '/pusher/auth', to: 'pusher#auth'

app/controllers/pusher_controller.rb:

class PusherController < ApplicationController
  def auth
    if user_signed_in?
      response = Pusher.authenticate(params[:channel_name], params[:socket_id], {
        user_id: current_user.id,
        user_info: { name: current_user.name },
      })
      render json: response
    else
      render json: { error: 'Forbidden' }, status: :forbidden
    end
  end
end

For a private channel, user_info is ignored. For a presence channel, it becomes the member payload visible to other subscribers in .here() and .joining() callbacks on the client.

ActionCable Pusher adapter (optional)

If you want to keep ActionCable channel conventions on the client and route through Vask, the actioncable-pusher-adapter npm package handles the translation layer. Configure it with your Vask credentials and your existing ActionCable channel subscriptions continue to receive messages, while WebSocket connections land on Cloudflare's edge rather than inside Puma.

This is an advanced option. If you are starting fresh on the Pusher-protocol path, use pusher-js directly.

Picking between the three: a flowchart

Do you have a Rails app?
                              |
                            YES
                              |
       Is ActionCable in-process a viable operating model?
       (low-medium traffic, single region, ops capacity)
                              |
                ┌─────────────┴─────────────┐
               YES                          NO
                |                            |
          ACTIONCABLE.          Are you OK with the
            Done.               per-fan-out billing
                                model on hosted Pusher?
                                          |
                              ┌───────────┴───────────┐
                             YES                       NO
                              |                         |
                           PUSHER.                   VASK.
                           Done.        (or another  Done.
                                         hosted
                                         Pusher-protocol
                                         service)

Three honest answers, all sharing the same pusher-http-ruby server SDK and pusher-js client. The decision is operating model and bill shape, not application code.

When NOT to switch from ActionCable

If you are running ActionCable in-process, the traffic fits, and the ops are fine, stay on ActionCable. Vask is the hosted Pusher-protocol option for teams that want broadcast-priced edge delivery without operating their own WebSocket layer. It is not the "next step" from ActionCable. It is a different shape of answer for a different shape of project.

The cases where moving off ActionCable actually makes sense:

  • You have outgrown a single-region model and your users are in multiple continents.
  • The Redis or PostgreSQL backplane adapter has become an operational concern at your traffic level.
  • You no longer want to hold WebSocket connections inside your Puma processes and manage the lifecycle around them.

None of those are about ActionCable being a bad fit. They are about the project changing shape. If your project has not changed shape, do not migrate.

What's the simplest way to add WebSockets to a Rails app in 2026?
ActionCable is the first-party answer shipped with Rails since Rails 5. It runs inside your Puma process, handles WebSocket upgrades natively, and integrates with your existing session and auth pipeline. For single-region apps where you own the deployment, ActionCable is the right call and you do not need a hosted vendor.
When does a hosted WebSocket service make sense over ActionCable?
Three scenarios. First, you want connections to terminate at the closest edge city rather than a single home region. Second, you do not want to manage the Action Cable subscription adapter (Redis or PostgreSQL) and the operational overhead that comes with it at scale. Third, your fan-out volume has grown to the point where your broadcast backplane is a bottleneck. Outside those cases, ActionCable in-process is the smaller, simpler answer.
How is Vask different from Pusher in a Rails app?
Both speak the Pusher Channels protocol on the wire, so your pusher-http-ruby backend and your pusher-js client work identically against either. The differences are where connections terminate and how the bill is calculated: Vask bills per broadcast, while per-message models multiply by subscriber count. Use your own fan-out factor to compare the bill.
Do private and presence channels work with Rails and the Pusher protocol?
Yes. Private channels (private-* prefix), presence channels (presence-* prefix), and channel auth (your /pusher/auth endpoint) are part of the Pusher Channels protocol. Pusher and Vask both implement it. Your pusher-js client, your server-side auth controller, and your trigger calls work identically against either. The one Rails-specific thing to handle is CSRF: your /pusher/auth endpoint must receive the X-CSRF-Token header from the client or skip CSRF verification explicitly.
Can I use the ActionCable Pusher adapter with Vask?
Yes. The ActionCable Pusher adapter lets you keep ActionCable channel conventions on the client and route WebSocket connections through a Pusher-protocol server instead. Configure it to point at your Vask endpoint and your ActionCable channel subscriptions continue to work, but connections land on Cloudflare's edge rather than inside your Puma processes.
Can I run ActionCable locally and Vask in production?
Many teams do this: ActionCable in development for zero extra config, pusher-js pointed at Vask in production via environment variables. The client code differs between the two paths (ActionCable consumer vs pusher-js Pusher instance), so this works best when you keep both paths thin and environment-switched rather than sharing the subscription logic.

Get going

If you want ActionCable, the Rails ActionCable guides are the canonical reference. If you want hosted Pusher protocol on Cloudflare's edge with broadcast-priced billing, add gem 'pusher' to your Gemfile, configure the initializer, and point your client at Vask.

Hosted Pusher protocol on the edge

Same Rails code. Different substrate.

Use Vask credentials, keep pusher-js and your trigger calls, and point the client at wss.vask.dev.