class ReportsController < ApplicationController
def create
@report = current_user.reports.create!(
title: report_params[:title],
status: :pending,
progress: 0
)
ReportGenerationJob.perform_later(@report)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.prepend(
"reports",
partial: "reports/report",
locals: { report: @report }
)
end
format.html { redirect_to reports_path }
end
end
private
def report_params
params.require(:report).permit(:title)
end
end
class ReportGenerationJob < ApplicationJob
queue_as :default
SECTIONS = %w[summary revenue cohorts appendix].freeze
def perform(report)
report.update!(status: :processing)
SECTIONS.each_with_index do |section, index|
build_section(report, section)
progress = ((index + 1) * 100.0 / SECTIONS.size).round
report.update!(progress: progress)
report.broadcast_replace_to(
[report, :progress],
target: ActionView::RecordIdentifier.dom_id(report),
partial: "reports/report",
locals: { report: report }
)
end
report.update!(status: :complete, progress: 100)
report.broadcast_replace_to(
[report, :progress],
target: ActionView::RecordIdentifier.dom_id(report),
partial: "reports/report",
locals: { report: report }
)
end
private
def build_section(report, name)
section_data = ReportBuilder.new(report).render(name)
report.sections.create!(name: name, body: section_data)
end
end
<%= turbo_stream_from report, :progress %>
<div id="<%= dom_id(report) %>" class="report-card report-card--<%= report.status %>">
<div class="report-card__header">
<h3><%= report.title %></h3>
<span class="report-card__status"><%= report.status.titleize %></span>
</div>
<% if report.complete? %>
<%= link_to "Download", report_path(report, format: :pdf), class: "btn btn--primary" %>
<% else %>
<div class="progress" role="progressbar" aria-valuenow="<%= report.progress %>">
<div class="progress__bar" style="width: <%= report.progress %>%"></div>
</div>
<p class="report-card__hint"><%= report.progress %>% complete</p>
<% end %>
</div>
This snippet shows how a long-running server operation can push incremental UI updates to the browser using Turbo Streams broadcast from an Active Job, so the page reflects progress without polling. The pattern separates three concerns: a controller that kicks off the work and returns immediately, a job that does the slow work and broadcasts as it goes, and a view that subscribes to a stream and renders each frame.
In ReportsController, the create action builds a Report record in a pending state and enqueues ReportGenerationJob with perform_later. The important detail is that the HTTP request does not wait for the report; it renders a turbo_stream response that appends a placeholder card into the reports list. That placeholder subscribes to a per-record channel via turbo_stream_from in _report.html.erb, which opens an Action Cable subscription keyed to the specific report. From this moment the browser is listening, and any later broadcast to that same key will be delivered live.
In ReportGenerationJob, the work is chunked so progress is observable. perform walks the report's sections and, after each one, calls broadcast_replace_to targeting [report, :progress] — the same key the view subscribed to. Each broadcast re-renders the _report partial with an updated progress value, so Turbo swaps the DOM element whose id matches dom_id(report). Because the target id is stable, repeated replaces are idempotent from the client's perspective: the latest frame always wins. When the loop finishes, the record is marked complete and one final replace renders the finished state.
The trade-off worth understanding is that broadcasts are fire-and-forget over Action Cable. If no subscriber is connected — for example the user navigated away — the message is simply dropped, which is why durable state still lives in the Report row rather than in the stream. This makes a page reload safe: the fresh render reflects whatever the job has committed so far. A common pitfall is broadcasting on every tiny step, which floods the socket; batching per section keeps the volume reasonable. This approach fits any slow operation — exports, imports, PDF rendering — where the user benefits from seeing forward motion instead of a spinner.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.