Building a Rails Engine #8 — Real-time Progress with ActionCable & Stimulus
Source: Dev.to
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.
Part 8 of the DataPorter series (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 is queued.
- The page sits there with no progress indicator.
No feedback, no way to know if the import is 10 % done, 90 % done, or has failed. 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 / After
| Before | After |
|---|---|
| click Import → spinner → ??? → refresh → refresh → done (maybe) | click Import → live progress bar 0 % → 50 % → 100 % → auto‑reload with results |
The Solution
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.
Flow (Server → 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:
- Broadcaster – knows how to format messages.
- ImportChannel – knows how to route them.
- Stimulus controller – knows how to render them.
None of them knows about the others’ internals.
1️⃣ Broadcaster
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: processing, success, failure.
The percentage is computed server‑side, so the client only needs to set a CSS width.
The channel name uses a configurable prefix (data_porter/imports/42) to avoid collisions with the host app’s channels.
Plugging into the Orchestrator
# 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.
2️⃣ ImportChannel
The thinnest class in the entire engine.
# app/channels/data_porter/import_channel.rb
module DataPorter
class ImportChannel {
if (data.status === "processing") {
this.updateProgress(data.percentage)
} else {
// success or failure – reload the page
window.location.reload()
}
}
}
)
}
updateProgress(percentage) {
if (this.hasBarTarget) {
this.barTarget.style.width = `${percentage}%`
this.textTarget.textContent = `${percentage}%`
}
}
disconnect() {
this.subscription?.unsubscribe()
}
}
Corresponding HTML
0%
The logic is two‑branch:
- processing → update the bar.
- anything else (success or failure) → reload the page.
Reloading is the simplest possible terminal action — the server‑rendered page shows the final state, no client‑side state management needed.
Design Choices Worth Noting
createConsumer()instead of a shared consumer — in an engine context we cannot assume the host app has already instantiated a consumer.- Configurable channel prefix (
DataPorter.configuration.cable_channel_prefix) prevents name collisions with the host application. - Thin layers – each component knows only what it needs to know, keeping the system maintainable and testable.
With these three pieces working together, imports now provide real‑time feedback to users, turning a frustrating “spinner‑only” experience into a smooth, informative progress bar. 🚀
Overview
- 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.
Channel Prefix
The channel prefix defaults to "data_porter" and can be overridden in an 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.
Decision
| Aspect | Chosen Option | Reason |
|---|---|---|
| Real‑time transport | ActionCable (WebSockets) | 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 | 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() | 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) | 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. |
Components
Broadcaster
A plain Ruby service that wraps ActionCable.server.broadcast with three semantic methods:
progresssuccessfailure
It builds the channel name from the configurable prefix and the import ID.
ImportChannel
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.
Stimulus Progress Controller
- Subscribes on
connect. - Updates a progress bar on processing messages.
- Reloads the page on success or failure.
- Cleans up the subscription on
disconnect.
Configuration
The cable_channel_prefix option prevents channel name collisions with the host app and gives operations teams control over naming.
Next Steps
The import now runs in the background and pushes live progress to the browser, but the progress bar needs a page to live on. In Part 9 we will:
- Build the view layer with Phlex and Tailwind.
- Generate preview tables from the target DSL.
- Add status badges.
- Scope CSS so it 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
Links
- GitHub:
- RubyGems: