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

Published: (March 5, 2026 at 08:00 AM EST)
6 min read
Source: Dev.to

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

  1. The user clicks “Import”.
  2. The job is queued.
  3. 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

BeforeAfter
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.
  • 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.

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

AspectChosen OptionReason
Real‑time transportActionCable (WebSockets)Rails ships ActionCable; no extra dependencies, integrates with existing auth, bidirectional (even though we only need server‑to‑client).
Broadcast granularityOne message per recordSimplicity; ActionCable broadcasts are cheap, and the Stimulus controller handles high‑frequency updates idempotently by just setting a CSS width.
Completion behaviorwindow.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 authorizationNone (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:

  • progress
  • success
  • failure

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:
0 views
Back to Blog

Related posts

Read more »