Skip to content
Development

Live updates from background jobs in Rails 8 with Turbo Streams

By Victor Da Luz
railsturbohotwiredev-logblog-manager

The scan button worked, but it felt incomplete. Click it, see “Scan started.” flash across the top, then stare at a row still showing “idle” until you hit refresh. The scan job was running fine; posts were being found, states were being updated. The page just had no idea.

This is the boring half of async jobs: the job works, but the UI doesn’t know. You either poll or you push. Rails has had the push story for a while with ActionCable and Turbo Streams. I’d been deferring it because I expected more setup than it turned out to need.

What I was building

Two separate updates, both targeting the same table row:

  1. Click Scan → row switches to “Scanning…” instantly (no reload)
  2. Job finishes → row updates to the result or error (no reload)

Both work by replacing the row’s <tr> using a stable DOM id. The first update comes from the controller responding with a Turbo Stream. The second comes from the job broadcasting a Turbo Stream over ActionCable.

Extracting the partial

The blogs index had the full row markup inline in a .each loop. That works when rows are static, but Turbo needs a stable DOM target. Step one was extracting everything into _blog.html.erb and giving the <tr> an id:

<tr id="<%= dom_id(blog) %>">
  ...
</tr>

dom_id is Rails’ built-in helper that returns "blog_1" for Blog.find(1). Once the partial exists, the index can use render blog directly; Rails finds _blog.html.erb and passes blog as the local automatically.

Subscribing the page

Inside the collection loop, one line subscribes each row to its own ActionCable channel:

<% @blogs.each do |blog| %>
  <%= turbo_stream_from blog %>
  <%= render blog %>
<% end %>

turbo_stream_from blog renders a <turbo-cable-stream-source> element that opens a WebSocket connection scoped to that record. When a broadcast arrives, the browser applies the Turbo Stream action directly.

The controller side

button_to submits as a Turbo Form by default, so the controller can respond with a stream:

def scan
  @blog.update!(scan_state: :running)
  BlogScanJob.perform_later(@blog)
  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.replace(@blog, partial: "blogs/blog", locals: { blog: @blog }) }
    format.html { redirect_to blogs_path }
  end
end

Setting scan_state: :running before enqueuing means the Turbo Stream response renders the “Scanning…” state immediately. The format.html fallback keeps things working without JavaScript.

The job side

Broadcasting is one method call per state transition:

Turbo::StreamsChannel.broadcast_replace_to(
  blog,
  target: dom_id(blog),
  partial: "blogs/blog",
  locals: { blog: blog }
)

dom_id is a view helper that lives in ActionView::RecordIdentifier. Jobs don’t include view modules by default, so:

class BlogScanJob < ApplicationJob
  include ActionView::RecordIdentifier
  ...
end

There’s a gotcha with the discard_on callback. It runs at class level, not instance level. Calling broadcast_blog(blog) inside the block fails; you have to call it via job.broadcast_blog(blog), which means the method needs to be public:

discard_on SomeError do |job, error|
  blog = job.arguments.first
  blog.update!(...)
  job.broadcast_blog(blog)  # via job., not bare method call
end

The gotcha that cost me a full test cycle

The controller-side update worked immediately. The post-job update never fired.

The root cause was in config/cable.yml, documented by a comment I hadn’t read:

# Async adapter only works within the same process...
development:
  adapter: async

bin/dev runs Puma and Solid Queue as separate processes. The async adapter is in-memory, scoped to a single process. Broadcasts from the Solid Queue worker go into a dead-end bus that the web server never reads.

The fix was one config key:

development:
  adapter: solid_cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

Solid Cable uses SQLite as the message broker. The worker writes a row to solid_cable_messages, the web server polls and delivers to the WebSocket. It was already installed (production uses it) and the migration had already run. I just hadn’t wired development to use it.

What surprised me

How little code it took once I understood the pieces. The Turbo wiring itself was probably 30 lines total. The async adapter discovery was the slow part.

Also: the discard_on class-level block is an easy trap. It looks like it’s in the same scope as instance methods, but it isn’t. If you have error-handling callbacks that need to broadcast, make the broadcast method public.

Related reading

Development

The follow-up audit a review pass asked for

One turbo_stream bug fixed twice, four more instances of the same shape found by applying a discriminator instead of a blanket rule - and the doc that was still teaching the broken version.

Read
Development

In-flight feedback for hero image actions

A polish ticket that split into two problems, a persisted flag that would've stuck a spinner on forever, and a branch-order bug three review angles flagged independently.

Read