Custom Turbo Stream Actions in Rails (Redirect view to new url from Background Jobs)
The Problem
You have a controller action that's too slow (timeouts). You want to move the work to a background job, show a spinner immediately, and redirect the user when the job finishes.
What You Need
1. Register a custom Turbo Stream action on the client
In your JavaScript entrypoint (e.g. app/javascript/application.js):
import * as Turbo from "@hotwired/turbo"
Turbo.StreamActions.redirect = function () {
Turbo.visit(this.target)
}
This tells Turbo: when you see <turbo-stream action="redirect" target="/some/path">, call Turbo.visit() on that path. Works for streams arriving via HTTP response or ActionCable broadcast.
2. Add a server-side helper to generate the tag
Create app/helpers/turbo_stream/actions_helper.rb:
module TurboStream::ActionsHelper
def redirect(url)
turbo_stream_action_tag :redirect, target: url
end
end
Turbo::Streams::TagBuilder.include(TurboStream::ActionsHelper)
This lets you write turbo_stream.redirect(url) in views/controllers. Optional — you can also use turbo_stream.action(:redirect, url) without this helper.
3. Subscribe the view to a broadcast channel
In the view where the user will be waiting, add a turbo_stream_from tag. This opens an ActionCable WebSocket subscription:
<%= turbo_stream_from @record, :my_channel %>
Important: This tag must be inside the active Turbo Frame if your app uses frame-based navigation. If it's outside the frame, it won't be connected when the page loads via frame navigation.
4. Add a target element for the spinner replacement
Wrap the button/area you want to replace with a targetable element:
<div id="processing_indicator">
<%= button_to "Do the thing", my_path, method: :patch %>
</div>
5. Create the background job
The job does the real work and broadcasts the result:
class MyJob < ApplicationJob
include Rails.application.routes.url_helpers
def perform(record_id, user_id)
record = MyRecord.find(record_id)
user = User.find(user_id)
if MyService.call(record, user)
Turbo::StreamsChannel.broadcast_action_to(
[record, :my_channel], # must match turbo_stream_from
action: :redirect,
target: success_path(record) # the URL to redirect to
)
else
Turbo::StreamsChannel.broadcast_replace_to(
[record, :my_channel],
target: "processing_indicator",
html: '<div id="processing_indicator">Something went wrong.</div>'
)
end
end
end
The stream name ([record, :my_channel]) must match what turbo_stream_from subscribes to.
6. Update the controller to enqueue the job
def my_action
MyJob.perform_later(@record.id, current_user.id)
respond_to do |format|
format.turbo_stream # renders the spinner template
format.html { redirect_to fallback_path, notice: "Processing..." }
end
end
7. Create the turbo_stream response template
app/views/my_controller/my_action.turbo_stream.erb:
<%= turbo_stream.replace "processing_indicator" do %>
<div id="processing_indicator">
<svg class="spinner">...</svg>
</div>
<% end %>
The Flow
User clicks button
→ Controller enqueues job, returns turbo_stream
→ turbo_stream replaces button with spinner
→ Page is subscribed to ActionCable channel via turbo_stream_from
→ Job runs in Sidekiq
→ Job broadcasts <turbo-stream action="redirect" target="/success/path">
→ Client-side Turbo.StreamActions.redirect calls Turbo.visit("/success/path")
→ User sees the success page
Prerequisites
turbo-railsgem (standard in Rails 7+)- ActionCable configured and running (Redis adapter for production)
- Sidekiq or another ActiveJob backend for background jobs
Gotchas
turbo_stream_frommust be inside the active Turbo Frame if you use frame-based navigation, otherwise the subscription is never established- Pass primitive IDs to jobs, not ActiveRecord objects — avoids serialization issues
broadcast_action_toaccepts anyaction:value — it's not limited to Turbo's built-in actions (append,replace, etc.)- The custom action name must match exactly between
Turbo.StreamActions.your_action(JS) andaction: :your_action(Ruby)