Real-time Progress with ActionCable & Stimulus
How to push live progress updates from a background import job to the browser using a Broadcaster service, an ActionCable channel, and a Stimulus controller -- so users never stare at a dead spinner again.
Context
This is part 8 of the series where we build DataPorter, a mountable Rails engine for data import workflows. In part 7, we built the Orchestrator -- the class that coordinates the parse-then-import workflow in the background via ActiveJob.
The problem
The user clicks "Import". The job goes into the queue. And the page sits there.
No progress indicator. No feedback. No way to know if the import is 10% done, 90% done, or failed entirely. The only option is to refresh and check the status column. For a 50,000-row CSV that takes two minutes, that is a terrible experience.
Before: click "Import" → spinner → ??? → refresh → refresh → done (maybe)
After: click "Import" → live progress bar 0%...50%...100% → auto-reload with results
Rails ships ActionCable, so we use it. The challenge is keeping the broadcasting layer decoupled from the Orchestrator and providing a Stimulus integration that works without the host developer writing any JavaScript.
What we're building
Here is the flow from server to browser:
Orchestrator#import!
|
|-- for each record:
| Broadcaster#progress(current, total)
| --> ActionCable.server.broadcast("data_porter/imports/42", { status: :processing, percentage: 65, ... })
| --> ImportChannel streams to subscriber
| --> Stimulus progress_controller updates the bar
|
|-- on success:
| Broadcaster#success
| --> { status: :success }
| --> Stimulus reloads the page
|
|-- on failure:
Broadcaster#failure(message)
--> { status: :failure, error: "..." }
--> Stimulus reloads the page
Three objects, three layers. The Broadcaster knows how to format messages. The ImportChannel knows how to route them. The Stimulus controller knows how to render them. None of them knows about the others' internals.
Implementation
Step 1 -- The Broadcaster service
The Broadcaster is a plain Ruby object that wraps ActionCable.server.broadcast with import-specific semantics:
# lib/data_porter/broadcaster.rb
module DataPorter
class Broadcaster
def initialize(import_id)
prefix = DataPorter.configuration.cable_channel_prefix
@channel = "#{prefix}/imports/#{import_id}"
end
def progress(current, total)
percentage = ((current.to_f / total) * 100).round
broadcast(status: :processing, percentage: percentage, current: current, total: total)
end
def success
broadcast(status: :success)
end
def failure(message)
broadcast(status: :failure, error: message)
end
private
def broadcast(message)
ActionCable.server.broadcast(@channel, message)
end
end
end
Three public methods, three states the browser cares about: work is in progress, work succeeded, or work failed. The percentage is computed server-side so the client just sets a CSS width. The channel name uses a configurable prefix (data_porter/imports/42) to avoid collisions with the host app's channels.
The Broadcaster plugs into the Orchestrator's import loop:
# Inside Orchestrator#import_records (conceptual)
broadcaster = Broadcaster.new(@data_import.id)
importable.each_with_index do |record, index|
persist_record(record, context, results)
broadcaster.progress(index + 1, importable.size)
end
broadcaster.success
One progress call per record. ActionCable broadcasts are cheap (in-memory pub/sub with the async adapter, Redis PUBLISH with Redis), and the Stimulus controller handles them idempotently -- it just sets a CSS width, so skipped frames cause no harm.
Step 2 -- The ImportChannel
The channel is the thinnest class in the entire engine:
# app/channels/data_porter/import_channel.rb
module DataPorter
class ImportChannel < ActionCable::Channel::Base
def subscribed
prefix = DataPorter.configuration.cable_channel_prefix
stream_from "#{prefix}/imports/#{params[:id]}"
end
end
end
That is the entire file. The channel constructs the same stream name the Broadcaster uses -- same prefix, same format. No authorization logic here: by the time the user sees the progress bar, the controller has already authorized access to that import.
Step 3 -- The Stimulus progress controller
On the browser side, a Stimulus controller subscribes to the channel and updates the DOM:
// app/javascript/data_porter/progress_controller.js
import { Controller } from "@hotwired/stimulus"
import { createConsumer } from "@rails/actioncable"
export default class extends Controller {
static targets = ["bar", "text"]
static values = { id: Number }
connect() {
this.subscription = createConsumer().subscriptions.create(
{ channel: "DataPorter::ImportChannel", id: this.idValue },
{
received: (data) => {
if (data.status === "processing") {
this.updateProgress(data.percentage)
} else {
window.location.reload()
}
}
}
)
}
updateProgress(percentage) {
if (this.hasBarTarget) {
this.barTarget.style.width = `${percentage}%`
this.textTarget.textContent = `${percentage}%`
}
}
disconnect() {
this.subscription?.unsubscribe()
}
}
The controller declares two targets (bar and text) and one value (id). The corresponding HTML looks like this:
<div data-controller="data-porter--progress" data-data-porter--progress-id-value="42">
<div data-data-porter--progress-target="bar" style="width: 0%"></div>
<span data-data-porter--progress-target="text">0%</span>
</div>
The logic is two branches: if processing, update the bar; for anything else (success or failure), reload the page. The reload is the simplest possible terminal action -- the server-rendered page shows the final state, no client-side state management needed.
A few design choices worth noting:
-
createConsumer()instead of a shared consumer -- in an engine context, we cannot assume the host app exports one. ActionCable deduplicates connections internally. -
hasBarTargetguard -- degrades gracefully during Turbo transitions where the DOM might be partially rendered. -
window.location.reload()on completion -- the engine does not need to ship Turbo Stream templates for the result screen. The server renders it once.
Step 4 -- Configuration
The channel prefix defaults to "data_porter" and is overridable in the initializer:
# config/initializers/data_porter.rb
DataPorter.configure do |config|
config.cable_channel_prefix = "my_app_imports"
end
This prevents channel name collisions if the host app runs multiple engines that use ActionCable.
Decisions & tradeoffs
| Decision | We chose | Over | Because |
|---|---|---|---|
| Real-time transport | ActionCable (WebSockets) | Polling or SSE | Rails ships ActionCable; no extra dependencies, integrates with existing auth, bidirectional even though we only need server-to-client |
| Broadcast granularity | One message per record | Batched (every N records) or throttled (every N seconds) | Simplicity; ActionCable broadcasts are cheap, and the Stimulus controller handles high-frequency updates idempotently by just setting a CSS width |
| Completion behavior | window.location.reload() |
Turbo Stream partial updates | The engine cannot predict what the host app's result page looks like; a full reload lets the server render the final state with its own layout and components |
| Channel authorization | None (deferred to controller) |
reject in subscribed based on user ownership |
The engine does not know the host's auth system; by the time the user sees the progress bar, the controller has already authorized access |
Recap
- The Broadcaster is a plain Ruby service that wraps
ActionCable.server.broadcastwith three semantic methods:progress,success, andfailure. It constructs the channel name from a configurable prefix and the import ID. - The ImportChannel is a one-method ActionCable channel that streams from the same channel name the Broadcaster writes to. It contains no authorization logic -- that responsibility stays in the controller layer.
- The Stimulus progress controller subscribes on
connect, updates a progress bar onprocessingmessages, reloads the page onsuccessorfailure, and cleans up the subscription ondisconnect. - The cable_channel_prefix configuration option prevents channel name collisions with the host app and gives operations teams control over naming.
Next up
The import now runs in the background and pushes live progress to the browser. But the progress bar needs a page to live on, and right now the engine has no UI. In part 9, we build the view layer with Phlex and Tailwind -- auto-generated preview tables from the target DSL, status badges, and scoped CSS that does not leak into the host app.
This is part 8 of the series "Building DataPorter - A Data Import Engine for Rails". Previous: The Orchestrator | Next: Building the UI with Phlex & Tailwind
GitHub: SerylLns/data_porter | RubyGems: data_porter
Top comments (0)