DEV Community

Cover image for Building a Rails Engine #8 — Real-time Progress with ActionCable & Stimulus
Seryl Lns
Seryl Lns

Posted on

Building a Rails Engine #8 — Real-time Progress with ActionCable & Stimulus

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.
  • hasBarTarget guard -- 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
Enter fullscreen mode Exit fullscreen mode

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.broadcast with three semantic methods: progress, success, and failure. 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 on processing messages, reloads the page on success or failure, and cleans up the subscription on disconnect.
  • 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)