Hotwire without Action Cable: Turbo Streams over Hibira in Rails
· Hibira
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.

The most charming thing about realtime in Rails is one line of code:
class Message < ApplicationRecord
broadcasts_to :room
endAdd <%= 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.
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.
Before
After
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.
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:
bundle add hibira-rails
bin/rails generate hibira:installThe 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:
HIBIRA_APP_ID=your-app-id
HIBIRA_APP_KEY=your-app-key
HIBIRA_APP_SECRET=your-app-secretTwo 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:
$ 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:

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.
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_channeland usecurrent_userinside it to decide who may subscribe to which channel; for presence channels, return the user's data. It inherits from yourApplicationController, so sign-in state and CSRF protection work as usual. - Webhooks: receive Hibira webhooks at
/hibira/webhookswith 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.pushsends iOS, Android and browser push, and/hibira/push/tokenissues 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:precompilewith Propshaft is verified). - Works in projects created with
--skip-action-cable.
hibira-rails is published on RubyGems, and the source is on GitHub under the MIT license. If you have a Hotwire app held up by Action Cable, give it a try: the Hibira 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.