ruby erb 84 lines · 3 tabs

Turbo Streams: broadcast from a job for long operations

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby, erb Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Turbo Streams: broadcast from a job for long operations — share card
Link copied