Progress indicators for long-running operations

Jordan Lee Jan 2026
3 tabs
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["bar", "percent", "status"]
  static values = {
    current: { type: Number, default: 0 },
    total: { type: Number, default: 100 }
  }

  connect() {
    this.updateProgress()
  }

  currentValueChanged() {
    this.updateProgress()
  }

  updateProgress() {
    const percent = Math.round((this.currentValue / this.totalValue) * 100)

    this.barTarget.style.width = `${percent}%`
    this.percentTarget.textContent = `${percent}%`

    if (percent >= 100) {
      this.complete()
    }
  }

  setProgress(event) {
    const { current, total, status } = event.detail

    this.currentValue = current
    this.totalValue = total

    if (status && this.hasStatusTarget) {
      this.statusTarget.textContent = status
    }
  }

  complete() {
    if (this.hasStatusTarget) {
      this.statusTarget.textContent = 'Complete!'
    }

    // Dispatch completion event
    this.element.dispatchEvent(new CustomEvent('progress:complete'))
  }
}
3 files · javascript, erb, ruby Explain with highlit

Users need feedback during slow operations like file uploads or complex processing. I combine Turbo Streams with background jobs to show real-time progress. When an operation starts, I enqueue a job that periodically broadcasts progress updates via Action Cable. The frontend subscribes to a user-specific channel and updates a progress bar as messages arrive. For file uploads, I use ActiveStorage's direct upload with progress events. The key is providing meaningful progress—showing percentage when possible, or indeterminate spinners when progress can't be measured. I also show estimated time remaining and allow cancellation when feasible. Clear progress indicators reduce perceived latency and abandonment rates.