# Hotwire without Action Cable: Turbo Streams over Hibira in Rails

> Why we built hibira-rails: keep broadcasts_to and turbo_stream_from as they are while Hibira carries the WebSocket connections. The one method it swaps, how signed streams stay safe, what happens to oversized broadcasts, and a five-minute walkthrough.

Source: https://hibira.io/blog/rails-turbo-streams
Updated: 2026-11-10

The most charming thing about realtime in Rails is one line of code:

```ruby
class Message < ApplicationRecord
  broadcasts_to :room
end
```

Add `<%= turbo_stream_from @room %>` to the page, and whenever someone posts a message, every browser that has the room open gets a new line at the same moment, without a single line of JavaScript. This is where Hotwire makes people fall in love with Rails.

The trouble shows up when you go to production: behind that line sits Action Cable. The WebSocket connections live in your own Puma, broadcasting across machines needs Redis or Solid Cable, your hosting platform caps connections, and as users grow you end up splitting the cable server out and scaling it on its own.

This post is the story of how we built `hibira-rails`: why we wanted it, the one place it swaps out, and how to plug an existing Rails app into it in five minutes.

## Behind that one line is a whole server

Action Cable isn't hard to use. It's hard to keep running. Every user with the page open holds a long-lived connection on your application server. Day to day you hardly notice, but during a traffic spike, a deploy restart or a server swap, those connections all drop together and reconnect together, while your Rails processes are also serving regular requests.

```text
flow: With Action Cable, connections and broadcasts live in your infrastructure
Browsers|thousands of WebSockets --long-lived--> Your Puma|HTTP and WebSockets together --publish--> Redis or Solid Cable|syncs across machines
Redis or Solid Cable --relay--> Your Puma --push--> Browsers
```

We started Hibira so teams wouldn't have to run that layer themselves. Hibira is a hosted realtime service that speaks the Pusher Channels protocol, so off-the-shelf libraries like pusher-js connect to it directly. The catch: Rails developers don't use pusher-js. They use `broadcasts_to` and `turbo_stream_from`. If switching services meant rewriting every model and view, that wouldn't be "plugging in", it would be a rewrite.

So we set ourselves one goal: **no changes to models, no changes to views. Once installed, that same line goes through Hibira.**

## Swapping just one place

Read the turbo-rails source and you'll find that whether you use `broadcasts_to`, `broadcast_append_to`, `broadcasts_refreshes` or `broadcast_replace_later_to` in a background job, everything ends up in one method: `Turbo::StreamsChannel.broadcast_stream_to`. It hands the rendered Turbo Stream HTML to Action Cable.

That gave us a very clean seam. `hibira-rails` overrides only that method, sending the same HTML to a Hibira channel instead. The page side works the same way: `turbo_stream_from` used to render `<turbo-cable-stream-source>`; now it renders `<hibira-turbo-stream-source>`, and a small piece of JavaScript subscribes to the channel and hands what arrives to Turbo. Turbo itself has no idea where the message came from.

```text
flow: With hibira-rails installed, the same broadcasts_to goes through Hibira
== Before
Model|broadcasts_to --> Turbo|StreamsChannel --> Action Cable|your Puma and Redis --WebSocket--> Browsers
== After
Model|broadcasts_to --> Turbo|StreamsChannel --one HTTPS publish--> *Hibira|fan-out --WebSocket--> Browsers
```

From then on your Rails processes do what they're good at: handle requests, render HTML, publish once. WebSocket connections, reconnects and fan-out are Hibira's job.

## Same security rule: no signature, no subscription

The scariest part of swapping a transport is accidentally swapping the permissions with it. Action Cable's rule is simple: `turbo_stream_from` puts a signed stream name in the page, and the server only accepts subscriptions with a valid signature. Only people who can see the page get the signature.

We kept exactly the same rule. Each Turbo stream maps to one private channel on Hibira, named after a hash of the stream name. To subscribe, the browser has to bring the signature from the page back to your Rails app in exchange for an authorization. A bad signature, or a channel that doesn't match the stream, is refused.

```text
sequence: Your Rails app checks the signature before a private channel subscription
Browser | Your Rails app | *Hibira
2 >> 1: Page with the signed stream name | turbo_stream_from
1 > 3: Connect | gets a socket_id
1 > 2: POST /hibira/turbo/auth | socket_id, channel, signature
2 >> 1: Authorization | signed with the app secret, which never leaves the server
1 > 3: subscribe | private-turbo-…
2 > 3: broadcasts_to fires | one turbo-stream event
3 > 1: turbo-stream | handed to Turbo to update the page
```

The app secret only exists on your server. The browser only ever gets the public app key.

## A walkthrough: an existing app in five minutes

We'll use a brand-new Rails 8.1 project, deliberately created with `--skip-action-cable`, to show the whole thing works without an Action Cable server.

Install the gem and run the installer:

```sh
bundle add hibira-rails
bin/rails generate hibira:install
```

The installer creates `config/initializers/hibira.rb`, pins pusher-js with importmap (or installs it with your package manager in a jsbundling project), and adds `import "hibira/turbo"` to `app/javascript/application.js`.

Then put the three values from your app page in the Hibira dashboard into environment variables, or under `hibira:` in `bin/rails credentials:edit`:

```sh
HIBIRA_APP_ID=your-app-id
HIBIRA_APP_KEY=your-app-key
HIBIRA_APP_SECRET=your-app-secret
```

Two things tend to trip people up here. The secret is shown only once, right after you create the app, so save it then; the app ID and key stay under **Overview → Credentials** on the app page. And on the Free plan, allowed origins can't be `*`: add every origin your pages load from, such as `http://localhost:3000` while developing plus your production domain, or browsers are refused when they connect.

And check them:

```text
$ bin/rails hibira:check
ok    App 1 on ws.hibira.io:443
ok    Credentials accepted by the REST API
ok    Turbo Streams go through Hibira
info  Push notifications off (set HIBIRA_PUSH_INSTANCE_ID and HIBIRA_PUSH_SECRET_KEY)
```

The model and view are straight from the Turbo handbook, not a character changed. Open two windows, send a message in one, and it appears in the other without a refresh:

![Two browser windows: a message sent in window B appears in window A right away, with no Action Cable in between.](https://hibira.io/portal/articles/rails-turbo-two-windows-en.webp)

The same holds after navigating. When Turbo changes pages, the old page's subscription is kept for a moment; if the new page subscribes to the same stream, it's reused instead of disconnecting and reconnecting.

## Your tests don't break

When broadcasts go to an outside service, tests are the first thing to break: a network call on every test run, or Turbo's built-in `assert_turbo_stream_broadcasts` suddenly failing.

So the default is `turbo = :auto`: go through Hibira whenever the keys are set, **but keep Action Cable's behaviour in the test environment**. Your existing broadcast assertions keep passing and CI never reaches outside. To force it on or off, set it to `true` or `false`.

## When a message is too big

This is the part that took the most thought. Turbo Streams carry rendered HTML. A product card might be a few hundred bytes; a whole table can be several KB. And every plan has a per-event size limit: 4 KB on the Free plan.

Over the limit, Hibira rejects the event. If nothing handled that, other users' pages would quietly stop updating, which is the worst outcome.

Our approach: when a broadcast is too big, automatically send a Turbo 8 page refresh instead. It's only a few dozen bytes, and the receiving page fetches the latest content itself. The page won't just gain one line the way an append does, but it will always be right.

```text
flow: A broadcast over the event limit becomes a page refresh
Model|broadcast_append_to --12 KB of HTML--> *Hibira|over the limit, rejected
*Hibira --reports too large--> hibira-rails|sends a refresh instead
hibira-rails --a few dozen bytes--> *Hibira --> Browsers|fetch the page again, always right
```

If your pages are long lists to begin with, we'd rather recommend Turbo 8's `broadcasts_refreshes` with `turbo_refreshes_with method: :morph`: every broadcast stays tiny, the page updates by morphing, and the scroll position doesn't jump. If you'd rather not have the automatic refresh, you can set it to log and drop, or to raise.

## Not just Turbo

With Rails and Hibira already connected, we took care of a few common jobs too:

- **Your own private and presence channels**: set `authorize_channel` and use `current_user` inside it to decide who may subscribe to which channel; for presence channels, return the user's data. It inherits from your `ApplicationController`, so sign-in state and CSRF protection work as usual.
- **Webhooks**: receive Hibira webhooks at `/hibira/webhooks` with the signature checked for you. During a secret rotation's grace period, webhooks signed with the old secret are still accepted, so you don't miss events before you redeploy.
- **Push notifications**: `Hibira.push` sends iOS, Android and browser push, and `/hibira/push/token` issues user tokens for the SDKs.

## What's supported

- Rails 7.0 to 8.1, Ruby 3.1 and up, turbo-rails 1.5 to 2. Every combination runs in CI.
- Works with importmap and jsbundling (`assets:precompile` with Propshaft is verified).
- Works in projects created with `--skip-action-cable`.

`hibira-rails` is published on [RubyGems](https://rubygems.org/gems/hibira-rails), and the source is on [GitHub](https://github.com/hibira-io/rails) under the MIT license. If you have a Hotwire app held up by Action Cable, give it a try: the [Hibira](https://hibira.io) free plan needs no credit card, and once it's installed, that `broadcasts_to` line no longer has to be carried by your servers.

We're building similar integrations for other frameworks and engines. Which one comes next? We'll reveal it in the next post. Stay tuned.
