Rails Development

Building Real-Time Features with Turbo Streams

After mastering Turbo Frames, I wanted to push further. What if multiple users could see each other's changes in real-time? What if new tasks appeared instantly for everyone, not just the person who created them?

That's where Turbo Streams come in. In this post, I'll show you how to add real-time functionality to your Rails app with surprisingly little code.

The Difference Between Frames and Streams

Before diving in, let's clarify the distinction:

Turbo Frames:
- Update content after user actions (clicks, form submissions)
- Only affect the current user's browser
- Replace content within frame boundaries

Turbo Streams:
- Push updates to multiple users simultaneously
- Work over WebSocket connections
- Can target any element on the page

Think of Frames as "interactive" and Streams as "live."

Setting Up Action Cable

Turbo Streams use Rails' Action Cable for WebSocket connections. Let's set it up:

1. Configure Action Cable

# config/cable.yml
development:
  adapter: async

test:
  adapter: test

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: task_app_production

2. Mount Action Cable

# config/routes.rb
Rails.application.routes.draw do
  mount ActionCable.server => '/cable'

  resources :tasks
  # ... other routes
end

3. Configure for Development

# config/environments/development.rb
Rails.application.configure do
  # ... other config

  # Action Cable endpoint configuration
  config.action_cable.url = "ws://localhost:3000/cable"
  config.action_cable.allowed_request_origins = [ "http://localhost:3000" ]
end

Building a Real-Time Task Board

Let's transform our task app into a real-time collaborative board where multiple users see updates instantly.

Step 1: Update the Task Model

Add broadcasting to the Task model:

# app/models/task.rb
class Task < ApplicationRecord
  validates :title, presence: true

  # Broadcast changes to all connected users
  after_create_commit  -> { broadcast_prepend_to "tasks", partial: "tasks/task", locals: { task: self } }
  after_update_commit  -> { broadcast_replace_to "tasks", partial: "tasks/task", locals: { task: self } }
  after_destroy_commit -> { broadcast_remove_to "tasks" }

  def toggle_complete!
    update(completed: !completed)
  end
end

Step 2: Subscribe to the Stream

In your main tasks view, subscribe to the "tasks" stream:

<!-- app/views/tasks/index.html.erb -->
<h1>Collaborative Task Board</h1>
<p><em>Updates appear in real-time for all users!</em></p>

<!-- Subscribe to the tasks stream -->
<%= turbo_stream_from "tasks" %>

<!-- New Task Form -->
<%= turbo_frame_tag "new_task" do %>
  <%= render partial: "new_task_form", locals: { task: @task } %>
<% end %>

<!-- Tasks List -->
<div id="tasks">
  <%= render partial: "task", collection: @tasks %>
</div>

Step 3: Update the Controller

Simplify the controller since broadcasting is handled in the model:

# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  def index
    @tasks = Task.all.order(created_at: :desc)
    @task = Task.new
  end

  def create
    @task = Task.new(task_params)

    if @task.save
      # Broadcasting happens automatically in the model
      @task = Task.new  # Reset for the form
      render :create
    else
      render :new, status: :unprocessable_entity
    end
  end

  def toggle_complete
    @task = Task.find(params[:id])
    @task.toggle_complete!
    # Update will be broadcast automatically
    head :ok
  end

  def destroy
    @task = Task.find(params[:id])
    @task.destroy
    # Removal will be broadcast automatically
    head :ok
  end

  private

  def task_params
    params.require(:task).permit(:title, :description)
  end
end

Step 4: Create Turbo Stream Templates

<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.replace "new_task" do %>
  <%= render partial: "new_task_form", locals: { task: @task } %>
<% end %>

<%= turbo_stream.prepend "task_flash" do %>
  <div class="flash-message">Task created successfully!</div>
<% end %>

Step 5: Update the Task Partial

Make sure each task has a unique ID for targeting:

<!-- app/views/tasks/_task.html.erb -->
<div class="task <%= 'completed' if task.completed? %>" id="<%= dom_id(task) %>">
  <div class="task-header">
    <h3><%= task.title %></h3>
    <div class="task-actions">
      <%= button_to "βœ“", task_toggle_complete_path(task), 
                    method: :patch, 
                    class: "btn-complete #{'active' if task.completed?}",
                    title: task.completed? ? "Mark incomplete" : "Mark complete" %>

      <%= button_to "βœ—", task_path(task), 
                    method: :delete, 
                    class: "btn-delete",
                    title: "Delete task",
                    confirm: "Are you sure?" %>
    </div>
  </div>

  <p class="task-description"><%= task.description %></p>
  <p class="task-meta">
    Created <%= time_ago_in_words(task.created_at) %> ago
  </p>
</div>

Testing Real-Time Functionality

To see the magic in action:

  1. Open multiple browser tabs to http://localhost:3000/tasks
  2. Create a task in one tab
  3. Watch it appear instantly in all other tabs
  4. Toggle completion in one tab
  5. See the update in all tabs immediately

It's genuinely magical when you see it working!

Advanced Stream Patterns

1. User-Specific Streams

Send updates only to specific users:

# app/models/task.rb
class Task < ApplicationRecord
  belongs_to :user

  after_create_commit -> { broadcast_to_user }

  private

  def broadcast_to_user
    broadcast_prepend_to "user_#{user.id}_tasks", 
                         partial: "tasks/task", 
                         locals: { task: self }
  end
end
<!-- In the view -->
<%= turbo_stream_from "user_#{current_user.id}_tasks" %>

2. Conditional Broadcasting

Only broadcast certain changes:

# app/models/task.rb
class Task < ApplicationRecord
  after_update_commit :broadcast_if_important_change

  private

  def broadcast_if_important_change
    if saved_change_to_completed? || saved_change_to_title?
      broadcast_replace_to "tasks", partial: "tasks/task", locals: { task: self }
    end
  end
end

3. Rich Stream Actions

Use all available stream actions:

# In a service or controller
def archive_completed_tasks
  completed_tasks = Task.where(completed: true)

  completed_tasks.each do |task|
    # Remove from active list
    Turbo::StreamsChannel.broadcast_remove_to "tasks", target: task

    # Add to archived list
    Turbo::StreamsChannel.broadcast_append_to "archived_tasks", 
                                               partial: "tasks/archived_task", 
                                               locals: { task: task }
  end

  # Show summary message
  Turbo::StreamsChannel.broadcast_update_to "tasks", 
                                             target: "summary",
                                             partial: "tasks/summary",
                                             locals: { archived_count: completed_tasks.count }
end

4. Temporary Notifications

Show flash messages that auto-dismiss:

# app/models/task.rb
after_create_commit :broadcast_creation_notice

private

def broadcast_creation_notice
  Turbo::StreamsChannel.broadcast_append_to "tasks",
                                             target: "notifications",
                                             partial: "shared/notification",
                                             locals: { 
                                               message: "#{title} was added to the board",
                                               type: "success"
                                             }
end
<!-- app/views/shared/_notification.html.erb -->
<div class="notification notification-<%= type %>" 
     data-controller="auto-dismiss" 
     data-auto-dismiss-delay-value="3000">
  <%= message %>
</div>

Building a Live Chat Feature

Let's add a simple chat system to our task board:

Create a Comment Model

rails generate model Comment task:references content:text user:string
rails db:migrate
# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :task
  validates :content, :user, presence: true

  after_create_commit -> { broadcast_append_to "task_#{task.id}_comments", 
                                                partial: "comments/comment", 
                                                locals: { comment: self } }
end

# app/models/task.rb
class Task < ApplicationRecord
  has_many :comments, dependent: :destroy
  # ... existing code
end

Add Comments Controller

# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  def create
    @task = Task.find(params[:task_id])
    @comment = @task.comments.build(comment_params)

    if @comment.save
      render turbo_stream: turbo_stream.replace("comment_form", 
                                                 partial: "comments/form", 
                                                 locals: { task: @task, comment: Comment.new })
    else
      render turbo_stream: turbo_stream.replace("comment_form", 
                                                 partial: "comments/form", 
                                                 locals: { task: @task, comment: @comment })
    end
  end

  private

  def comment_params
    params.require(:comment).permit(:content, :user)
  end
end

Add Routes

# config/routes.rb
resources :tasks do
  resources :comments, only: [:create]
end

Create Comment Views

<!-- app/views/comments/_comment.html.erb -->
<div class="comment" id="<%= dom_id(comment) %>">
  <strong><%= comment.user %>:</strong>
  <span><%= comment.content %></span>
  <small class="timestamp"><%= time_ago_in_words(comment.created_at) %> ago</small>
</div>
<!-- app/views/comments/_form.html.erb -->
<%= form_with model: [task, comment], class: "comment-form" do |form| %>
  <div class="form-row">
    <%= form.text_field :user, placeholder: "Your name", required: true %>
    <%= form.text_field :content, placeholder: "Add a comment...", required: true %>
    <%= form.submit "Post", class: "btn-post" %>
  </div>
<% end %>

Update Task View

<!-- app/views/tasks/_task.html.erb -->
<div class="task <%= 'completed' if task.completed? %>" id="<%= dom_id(task) %>">
  <!-- Existing task content -->

  <!-- Comments section -->
  <div class="task-comments">
    <%= turbo_stream_from "task_#{task.id}_comments" %>

    <div id="task_<%= task.id %>_comments">
      <%= render partial: "comments/comment", collection: task.comments %>
    </div>

    <div id="comment_form">
      <%= render partial: "comments/form", locals: { task: task, comment: Comment.new } %>
    </div>
  </div>
</div>

Performance Considerations

1. Limit Stream Subscribers

# Don't broadcast to too many streams
# Instead of user-specific streams for many users, consider:
after_create_commit -> { broadcast_to_room }

private

def broadcast_to_room
  # Broadcast to room/project level instead of individual users
  broadcast_prepend_to "project_#{project_id}_tasks"
end

2. Throttle Updates

# app/models/task.rb
class Task < ApplicationRecord
  after_update_commit :broadcast_update, if: :should_broadcast?

  private

  def should_broadcast?
    # Only broadcast significant changes
    saved_change_to_title? || 
    saved_change_to_completed? || 
    saved_change_to_description?
  end
end

3. Batch Operations

# For bulk operations, batch the streams
def complete_all_tasks
  tasks = Task.where(completed: false)

  tasks.update_all(completed: true)

  # Single broadcast for all updates
  Turbo::StreamsChannel.broadcast_replace_to "tasks", 
                                              target: "task_list",
                                              partial: "tasks/task_list",
                                              locals: { tasks: Task.all }
end

Debugging Stream Issues

1. Check WebSocket Connection

In browser console:
```javascript
// Check if Action Cable is connected
App.cable.connection.isOpen()

// Monitor connection events
App.cable.connection.monitor.addEventListener('connected', () => {
console.log('WebSocket connected')
})
```

2. Verify Stream Names

# In model
after_create_commit -> { 
  Rails.logger.info "Broadcasting to: tasks"
  broadcast_prepend_to "tasks"
}

3. Test Without Broadcasting

# Temporarily disable to test UI
# after_create_commit -> { broadcast_prepend_to "tasks" }

Common Pitfalls

1. N+1 Queries in Broadcasts

# Bad
after_create_commit -> { broadcast_prepend_to "tasks" }

# Good - ensure associations are loaded
after_create_commit -> { broadcast_prepend_to "tasks", partial: "tasks/task", locals: { task: self } }

2. Broadcasting in Loops

# Bad - creates many broadcasts
tasks.each { |task| task.update(completed: true) }

# Good - batch update, single broadcast
Task.where(id: task_ids).update_all(completed: true)
broadcast_replace_to "tasks", partial: "tasks/list"

3. Missing Stream Subscriptions

Always ensure views include <%= turbo_stream_from "stream_name" %>

What I Learned

Building real-time features with Turbo Streams taught me:

  1. WebSockets can be simple - No need for complex JavaScript libraries
  2. Broadcasting is powerful - Small changes create big user experience improvements
  3. Performance matters - Don't broadcast everything to everyone
  4. Testing is crucial - Real-time features need careful testing
  5. Progressive enhancement works - Features work without WebSockets, better with them

Next Steps

With Turbo Streams mastered, you can build:
- Collaborative editing - Multiple users editing the same document
- Live notifications - Real-time alerts and updates
- Activity feeds - Live streams of user actions
- Chat systems - Real-time messaging
- Live dashboards - Metrics that update automatically

In my next post, I'll show you how to add JavaScript behavior with Stimulus controllers, completing the Hotwire toolkit.

Key takeaways:
- Turbo Streams enable real-time multi-user features
- WebSocket setup is handled by Action Cable
- Broadcasting happens in model callbacks
- Multiple stream actions can target different page elements
- Performance and debugging are crucial for production apps

Try adding real-time features to your own app. Start with something simple like live notifications, then expand to more collaborative features!


Next up: "Stimulus Controllers: Adding Just Enough JavaScript" - We'll learn how to add client-side behavior while keeping the server-side mindset.

Christopher Lim

Christopher Lim

Rails developer and Unity explorer. Family man, lifelong learner, and builder turning ideas into polished applications. Passionate about quality software development and continuous improvement.

Back to All Posts
Reading time: 8 min read

Enjoyed this rails development post?

Follow me for more insights on rails development, Rails development, and software engineering excellence.