Turbo Streams in Rails 8: Beyond Basics

Turbo Streams enable real-time page updates over WebSocket connections without custom JavaScript. While the basics are straightforward, production applications demand more sophisticated patterns. This guide covers broadcasting strategies, custom stream actions, and patterns that scale.

Understanding Stream Architecture

Turbo Streams work through two delivery mechanisms: inline responses from controller actions and broadcasts over Action Cable. The inline approach handles form submissions, while broadcasts push updates to all subscribed clients simultaneously.

The key insight: streams modify the DOM using seven built-in actions—append, prepend, replace, update, remove, before, and after. Each action targets elements by DOM ID and applies changes atomically.

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :post
  belongs_to :user

  broadcasts_to :post, inserts_by: :prepend, target: "comments"

  after_create_commit :notify_post_author

  private

  def notify_post_author
    broadcast_append_to(
      post.user,
      target: "notifications",
      partial: "notifications/comment",
      locals: { comment: self }
    )
  end
end

The broadcasts_to macro handles create, update, and destroy callbacks automatically. The association-based stream name ensures only users viewing that specific post receive updates. Custom broadcasts in callbacks provide granular control for cross-cutting concerns like notifications.

Controller Response Patterns

Inline Turbo Stream responses work best for actions where the initiating user needs different feedback than passive observers. Consider a task completion feature where the user clicking "Complete" sees immediate confirmation while teammates see a status update.

# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  def complete
    @task = Current.user.tasks.find(params[:id])
    @task.complete!

    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.replace(@task, partial: "tasks/task", locals: { task: @task }),
          turbo_stream.update("flash", partial: "shared/flash", locals: { message: "Task completed!" }),
          turbo_stream.update("task_count", Current.user.tasks.pending.count)
        ]
      end
      format.html { redirect_to tasks_path, notice: "Task completed!" }
    end
  end
end

Multiple stream actions in a single response enable coordinated UI updates. The array syntax keeps related changes together, while the HTML fallback ensures functionality without JavaScript.

Stream Authorization and Security

Stream subscriptions require explicit authorization. Without it, any user could subscribe to any stream name and receive sensitive data. Rails 8 provides the Turbo::StreamsChannel with built-in verification.

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if verified_user = User.find_by(id: cookies.encrypted[:user_id])
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

# app/models/project.rb
class Project < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
  has_many :tasks

  def broadcast_target
    "project_#{id}_tasks"
  end
end

# app/views/projects/show.html.erb
<%= turbo_stream_from @project, :tasks if @project.users.include?(Current.user) %>

The signed stream name generated by turbo_stream_from prevents tampering. Verifying membership before rendering the subscription tag adds application-level authorization. Streams broadcast to that channel reach only authorized, connected users.

Custom Stream Actions

Built-in actions cover most scenarios, but complex interfaces sometimes need custom behavior. Rails 8 supports registering custom Turbo Stream actions that execute arbitrary JavaScript.

// app/javascript/turbo_streams/highlight_action.js
import { StreamActions } from "@hotwired/turbo"

StreamActions.highlight = function() {
  const target = this.targetElements[0]
  if (!target) return

  target.classList.add("highlight-pulse")
  setTimeout(() => target.classList.remove("highlight-pulse"), 2000)
}

// app/javascript/application.js
import "./turbo_streams/highlight_action"

# Usage in Ruby:
# turbo_stream.action(:highlight, "comment_#{@comment.id}")

Custom actions bridge the gap between declarative stream updates and imperative JavaScript. The targetElements property provides access to matched DOM elements, enabling animations, focus management, or third-party library integration.

Broadcasting from Background Jobs

Long-running operations should broadcast from Solid Queue jobs rather than blocking web requests. This pattern keeps response times fast while ensuring updates reach users when processing completes.

# app/jobs/report_generation_job.rb
class ReportGenerationJob < ApplicationJob
  queue_as :default

  def perform(report)
    report.update!(status: "processing")
    broadcast_status(report)

    result = ReportGenerator.new(report).generate
    report.update!(status: "completed", file: result)

    broadcast_completion(report)
  rescue StandardError => e
    report.update!(status: "failed", error_message: e.message)
    broadcast_status(report)
    raise
  end

  private

  def broadcast_status(report)
    Turbo::StreamsChannel.broadcast_replace_to(
      report.user,
      target: "report_#{report.id}",
      partial: "reports/report",
      locals: { report: report }
    )
  end

  def broadcast_completion(report)
    Turbo::StreamsChannel.broadcast_append_to(
      report.user,
      target: "downloads",
      partial: "reports/download_link",
      locals: { report: report }
    )
  end
end

Broadcasting directly from jobs via Turbo::StreamsChannel bypasses model callbacks. This explicit approach provides clearer control flow and error handling. Status transitions broadcast incrementally, keeping users informed throughout processing.

Handling Disconnections and Race Conditions

WebSocket connections drop. Users switch tabs, lose network connectivity, or close laptops. Robust applications handle reconnection gracefully by reconciling state.

Turbo automatically reconnects dropped connections and resubscribes to streams. However, updates broadcast during disconnection are lost. For critical data, implement a staleness check that triggers a full refresh when reconnection occurs.

// app/javascript/controllers/reconnect_controller.js
import { Controller } from "@hotwired/stimulus"
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"

export default class extends Controller {
  static values = { url: String }

  connect() {
    document.addEventListener("turbo:connected", this.handleReconnect)
  }

  disconnect() {
    document.removeEventListener("turbo:connected", this.handleReconnect)
  }

  handleReconnect = () => {
    if (this.wasDisconnected) {
      fetch(this.urlValue, { headers: { Accept: "text/vnd.turbo-stream.html" } })
        .then(response => response.text())
        .then(html => Turbo.renderStreamMessage(html))
    }
    this.wasDisconnected = false
  }
}

This Stimulus controller tracks disconnection state and fetches fresh content upon reconnection. The endpoint returns Turbo Stream responses that reconcile any missed updates.

Summary and Next Steps

Turbo Streams eliminate JavaScript complexity while enabling sophisticated real-time features. Key patterns include using model broadcasts for standard CRUD, controller responses for user-specific feedback, and job broadcasts for async operations.

Authorization at both the channel and application level prevents data leaks. Custom actions extend capabilities when built-in actions fall short. Reconnection handling ensures reliability on unstable networks.

For applications already using Turbo Frames, adding Streams unlocks collaborative features—live comments, presence indicators, and real-time dashboards—without architectural changes. Start with a single broadcast on an existing model and expand from there.

21 claps
← Back to Blog