class ImportRun < ApplicationRecord
enum status: { pending: 0, running: 1, completed: 2, failed: 3 }
def percent
return 0 if total.to_i.zero?
[(processed.to_f / total * 100).round, 100].min
end
def bump!(by = 1)
increment!(:processed, by)
self
end
def broadcast_progress
broadcast_replace_to(
self,
target: ActionView::RecordIdentifier.dom_id(self),
partial: "import_runs/progress",
locals: { run: self }
)
end
end
class ImportJob < ApplicationJob
queue_as :imports
rescue_from(StandardError) do |error|
if (run = arguments.first).is_a?(ImportRun)
run.update!(status: :failed)
run.broadcast_progress
end
raise error
end
after_perform do |job|
run = job.arguments.first
run.update!(status: :completed)
run.broadcast_progress
end
def perform(run)
rows = CsvSource.new(run.source_path).rows
run.update!(total: rows.size, status: :running, processed: 0)
run.broadcast_progress
rows.each_with_index do |row, index|
RowImporter.call(run, row)
run.bump!
run.broadcast_progress if (index % 25).zero?
end
run.broadcast_progress
end
end
<%= turbo_stream_from run %>
<div id="<%= dom_id(run) %>" class="import-progress">
<div class="import-progress__meta">
<span><%= run.status.humanize %></span>
<span><%= run.processed %> / <%= run.total %></span>
</div>
<div class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="<%= run.percent %>">
<div class="progress-bar__fill progress-bar__fill--<%= run.status %>"
style="width: <%= run.percent %>%;"></div>
</div>
<% if run.failed? %>
<p class="import-progress__error">Import failed. Check the logs and retry.</p>
<% end %>
</div>
class ImportRunsController < ApplicationController
def create
@run = ImportRun.create!(source_path: params.require(:source_path), status: :pending)
ImportJob.perform_later(@run)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.append(
"import_runs",
partial: "import_runs/progress",
locals: { run: @run }
)
end
format.html { redirect_to import_run_path(@run) }
end
end
def show
@run = ImportRun.find(params[:id])
end
end
This snippet shows how a long-running background job can push a live progress bar to the browser using Turbo Streams, without writing any custom JavaScript for the updates. The pattern relies on three collaborating pieces: an ImportRun model that persists progress as durable state, an ImportJob that does the work and broadcasts incremental updates, and a _progress partial that Turbo replaces in place on every update.
In ImportRun model, progress is treated as a first-class attribute rather than something ephemeral in memory. The record holds total, processed, and status columns, and percent derives a bounded integer so the view stays dumb. Storing progress in the database matters because a browser can disconnect and reconnect mid-import; when the page reloads it renders the current percent straight from the row instead of losing the bar. The bump! method advances processed and returns the run so the job can chain a broadcast, while broadcast_progress re-renders the partial and pushes it over the stream named after the record via broadcast_replace_to.
The ImportJob is where the work and the throttling live. GlobalID-backed arguments let Active Job serialize the ImportRun directly. It sets total, marks the run running, then iterates the source rows. A key detail is that it does not broadcast on every single row — broadcasting thousands of times per second would flood Action Cable and jank the UI — so it only calls broadcast_progress every 25 rows and once at the end. The after_perform and rescue_from callbacks guarantee a terminal broadcast: success flips the run to completed, and any exception flips it to failed before re-raising, so the bar never gets stuck at an ambiguous state.
The _progress partial is an ordinary ERB fragment wrapped in a turbo_frame-style DOM id (dom_id(run)) that matches the broadcast target. Its aria-valuenow and width style are computed from percent, giving an accessible, CSS-driven bar. Because broadcast_replace_to targets that same id, each broadcast swaps the whole fragment atomically.
The main trade-off is that progress accuracy depends on total being known up front; for unbounded streams an indeterminate bar or a periodic timer broadcast is a better fit. This approach is ideal when the total count is cheap to compute and users need reassurance a slow import is actually moving.
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.