ruby erb 103 lines · 4 tabs

Broadcast a status badge update on background processing

Shared by codesnips Jan 2026
4 tabs
class Document < ApplicationRecord
  belongs_to :account

  enum status: { pending: 0, processing: 1, ready: 2, failed: 3 }

  after_create_commit :broadcast_badge

  def transition_to!(new_status)
    unless valid_transition?(new_status)
      raise ArgumentError, "illegal transition #{status} -> #{new_status}"
    end

    update!(status: new_status)
    broadcast_badge
  end

  private

  def valid_transition?(new_status)
    allowed = {
      "pending"    => %w[processing],
      "processing" => %w[ready failed],
      "failed"     => %w[processing]
    }
    allowed.fetch(status, []).include?(new_status.to_s)
  end

  def broadcast_badge
    broadcast_replace_later_to(
      [account, :documents],
      target: "document_#{id}_badge",
      partial: "documents/badge",
      locals: { document: self }
    )
  end
end
4 files · ruby, erb Explain with highlit

This snippet shows how a background job pushes live status updates to the browser over Turbo Streams, so a status badge changes from pending to processing to ready without the user reloading. It is the standard Hotwire pattern for long-running work: the request enqueues a job and returns immediately, while the model broadcasts each state transition to a stream the page is subscribed to.

In Document model, the record owns its own broadcast logic. The status enum defines the lifecycle, and the private broadcast_badge method uses broadcast_replace_later_to to render the documents/badge partial into the DOM element whose target is document_#{id}_badge. Broadcasting is centralized in transition_to!, which validates the move using enqueue_processing! semantics and persists the new state; every write funnels through one place so no transition can silently skip a broadcast. Using broadcast_replace_later_to (the _later variant) offloads the actual render to a job, keeping the calling job's thread free and avoiding rendering inside a database transaction.

In ProcessDocumentJob, the work is wrapped so the badge is honest about failure. The job flips the document to processing, runs the real extraction, then moves to ready. A rescue block transitions to failed and re-raises so Active Job's retry machinery still sees the error — the badge reflects reality while the retry backoff proceeds. Because each transition_to! call broadcasts, the user sees the spinner appear the instant the job picks up the work, not just when it finishes.

In DocumentsController, create saves the record, enqueues ProcessDocumentJob, and renders a Turbo Stream that prepends the new row. The freshly rendered documents/badge partial subscribes to the same stream via turbo_stream_from, so the element is listening before any background broadcast arrives.

The trade-off is eventual consistency: broadcasts are best-effort over Action Cable, so a disconnected client can miss an update and show a stale badge. Rendering the current status on page load, as _badge.html.erb does, means a refresh always corrects drift. Reaching for this pattern makes sense when work is slow enough to need feedback but not worth polling for.


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.

Broadcast a status badge update on background processing — share card
Link copied