class ExportJob < ApplicationJob
def perform(export_id)
export = Export.find(export_id)
export.update!(status: :processing, percent: 0)
broadcast(export)
10.times do |i|
sleep 0.2
export.update!(percent: (i + 1) * 10)
broadcast(export)
end
export.update!(status: :completed)
broadcast(export)
end
private
def broadcast(export)
Turbo::StreamsChannel.broadcast_replace_to(
export,
target: dom_id(export, :progress),
partial: 'exports/progress',
locals: { export: export }
)
end
end
<%= turbo_frame_tag dom_id(export, :progress) do %>
<div class="text-sm"><%= export.status %> – <%= export.percent %>%</div>
<div class="mt-1 h-2 w-full rounded bg-gray-200">
<div class="h-2 rounded bg-blue-500" style="width: <%= export.percent %>%"></div>
</div>
<% end %>
Long-running jobs are where Hotwire can feel magical: start an export, then watch progress update live. I give each job a “progress” model, render it in a turbo_frame_tag, and broadcast replacements as the job advances. The job updates percent and status, then uses Turbo::StreamsChannel.broadcast_replace_to to replace the frame content. The UI stays server-rendered, so you don’t need a websocket protocol for progress—just send rendered HTML fragments. This is also easy to secure because the stream can be scoped to [current_member, export]. I keep the broadcast payload small and avoid calling expensive methods inside the partial since it may render many times during a single job.