Rails Turbo and Hotwire in 2026: Real-Time Applications and Interview Questions

Master Rails Turbo and Hotwire for building real-time web applications. Deep dive into Turbo 8 morphing, Turbo Streams broadcasting, Stimulus integration, and interview preparation.

Rails Turbo and Hotwire real-time web application architecture diagram

Rails Turbo and Hotwire deliver real-time interactivity to web applications without requiring custom JavaScript frameworks. The 2026 ecosystem has matured significantly, with Turbo 8's morphing capabilities and Rails 8's enhanced broadcasting making reactive UIs achievable with minimal code. This deep dive covers production patterns, interview preparation, and implementation details for building modern Rails applications.

Key Takeaway

Turbo 8's morphing with broadcasts_refreshes enables real-time updates with a single line of Ruby code. The server broadcasts a signal, clients morph the DOM, and scroll position is preserved automatically.

Understanding the Hotwire Architecture in 2026

Hotwire consists of three complementary technologies that work together to create reactive web experiences. Turbo Drive intercepts navigation and form submissions, replacing page content via AJAX without full reloads. Turbo Frames scope updates to specific page regions, enabling inline editing and lazy loading. Turbo Streams deliver granular DOM changes over WebSocket connections or HTTP responses.

The architecture follows a server-centric model where HTML remains the primary payload. Unlike SPAs that transfer JSON and render on the client, Hotwire applications render HTML server-side and stream it to browsers. This approach simplifies state management, improves SEO, and reduces JavaScript bundle sizes.

Rails 8 ships with Hotwire 2.0 by default, integrating Action Cable for WebSocket support. The framework handles connection management, channel subscriptions, and broadcasting without manual configuration.

Turbo Drive: Zero-Configuration SPA Navigation

Turbo Drive transforms standard links and forms into AJAX-powered interactions. When enabled, clicking any link triggers a fetch request, the response body replaces the current page content, and the browser history updates appropriately.

ruby
# app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

The data-turbo-track: "reload" attribute ensures asset changes trigger full page reloads, preventing stale CSS or JavaScript. The turbo_refreshes_with helper configures morphing behavior globally, though it can be overridden per-page.

Disabling Turbo Drive for specific links requires the data-turbo attribute:

erb
<!-- Disable Turbo for external links -->
<%= link_to "Download PDF", document_path(@doc, format: :pdf), data: { turbo: false } %>

<!-- Force full page reload -->
<%= link_to "Admin Panel", admin_path, data: { turbo_action: "replace" } %>

Turbo Frames: Scoped Page Updates

Turbo Frames isolate page sections into independently updatable regions. A frame wraps content with a unique identifier, and any navigation within that frame updates only the wrapped content.

erb
<!-- app/views/comments/index.html.erb -->
<%= turbo_frame_tag "comments" do %>
  <% @comments.each do |comment| %>
    <%= render comment %>
  <% end %>
  
  <%= link_to "Load more", comments_path(page: @page + 1) %>
<% end %>

The server response must include a matching frame tag. Turbo extracts the frame content and replaces the existing frame in the DOM:

erb
<!-- app/views/comments/index.html.erb (paginated response) -->
<%= turbo_frame_tag "comments" do %>
  <% @comments.each do |comment| %>
    <%= render comment %>
  <% end %>
  
  <% if @comments.any? %>
    <%= link_to "Load more", comments_path(page: @page + 1) %>
  <% end %>
<% end %>

Lazy loading frames defer content loading until the frame enters the viewport:

erb
<%= turbo_frame_tag "user_stats", src: user_stats_path(@user), loading: :lazy do %>
  <div class="animate-pulse bg-gray-200 h-24"></div>
<% end %>

Turbo Streams: Real-Time DOM Manipulation

Turbo Streams provide seven actions for manipulating the DOM: append, prepend, replace, update, remove, before, and after. Each action targets an element by ID and applies the specified change.

erb
<!-- app/views/comments/create.turbo_stream.erb -->
<%= turbo_stream.prepend "comments" do %>
  <%= render @comment %>
<% end %>

<%= turbo_stream.update "comment_count" do %>
  <%= @comments_count %> comments
<% end %>

<%= turbo_stream.replace "new_comment_form" do %>
  <%= render "form", comment: Comment.new %>
<% end %>

Controllers respond to Turbo Stream requests alongside HTML:

ruby
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  def create
    @comment = @post.comments.build(comment_params)
    
    respond_to do |format|
      if @comment.save
        format.turbo_stream
        format.html { redirect_to @post, notice: "Comment added" }
      else
        format.turbo_stream do
          render turbo_stream: turbo_stream.replace(
            "new_comment_form",
            partial: "form",
            locals: { comment: @comment }
          )
        end
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end
end

Ready to ace your Ruby on Rails interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Turbo 8 Morphing: Intelligent DOM Updates

Turbo 8 introduced morphing, powered by the idiomorph library. Instead of replacing entire page sections, morphing compares the current DOM with incoming HTML and applies minimal changes. This preserves form state, scroll position, and focus.

ruby
# app/models/message.rb
class Message < ApplicationRecord
  belongs_to :conversation
  
  broadcasts_refreshes
end

The broadcasts_refreshes callback schedules a background job when records change. The job broadcasts a refresh signal to all subscribed clients via Action Cable. Clients fetch the current page HTML and morph their DOM to match.

erb
<!-- app/views/conversations/show.html.erb -->
<%= turbo_stream_from @conversation %>

<div id="messages">
  <%= render @conversation.messages %>
</div>

<%= render "messages/form", conversation: @conversation %>

The turbo_stream_from helper subscribes the page to a Turbo Streams channel. When any message in the conversation changes, all viewers see updates instantly.

Morphing Configuration Options

Fine-grained control over morphing behavior uses data attributes:

erb
<!-- Preserve element across morphs -->
<div data-turbo-permanent id="video-player">
  <video src="<%= @video.url %>" autoplay></video>
</div>

<!-- Skip morphing, always replace -->
<div data-turbo-method="replace" id="chart-container">
  <%= render "chart", data: @analytics %>
</div>

The refresh debouncer prevents excessive broadcasts. When multiple database updates occur rapidly, only one refresh triggers after a 500ms delay. This is configurable per model:

ruby
# app/models/task.rb
class Task < ApplicationRecord
  broadcasts_refreshes debounce: 1.second
end

Interview Questions: Hotwire Fundamentals

Technical interviews frequently assess Hotwire knowledge through conceptual and implementation questions.

Q: How does Turbo Drive differ from traditional AJAX navigation libraries?

Turbo Drive intercepts all navigation automatically without explicit configuration. It preserves the browser's back/forward button behavior, updates the URL via pushState, and handles redirects transparently. The key distinction is progressive enhancement: applications work without JavaScript, gaining speed improvements when JavaScript loads.

Q: When should Turbo Frames be preferred over Turbo Streams?

Turbo Frames suit navigation-based interactions where clicking a link should update a specific page region. Frames maintain URL semantics and work with browser history. Turbo Streams handle broadcast scenarios where multiple elements need updating simultaneously or where updates originate from background processes rather than user navigation.

Q: Explain the morphing refresh cycle in Turbo 8.

When broadcasts_refreshes triggers, Rails enqueues Turbo::Streams::BroadcastStreamJob. The job broadcasts a refresh action to the stream name derived from the model. Subscribed clients receive the action, fetch the current page via GET request, and pass both DOMs to idiomorph. The library calculates the minimal diff and applies changes, preserving elements marked data-turbo-permanent.

Building a Real-Time Notification System

A practical implementation demonstrates Hotwire's capabilities for real-time features:

ruby
# app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :user
  
  after_create_commit :broadcast_to_user
  
  private
  
  def broadcast_to_user
    broadcast_prepend_to(
      "notifications_#{user_id}",
      target: "notifications",
      partial: "notifications/notification",
      locals: { notification: self }
    )
    
    broadcast_update_to(
      "notifications_#{user_id}",
      target: "unread_count",
      html: user.notifications.unread.count.to_s
    )
  end
end

The view subscribes to the user-specific channel:

erb
<!-- app/views/layouts/_navbar.html.erb -->
<nav>
  <%= turbo_stream_from "notifications_#{current_user.id}" %>
  
  <div class="relative">
    <button>Notifications</button>
    <span id="unread_count" class="badge">
      <%= current_user.notifications.unread.count %>
    </span>
    
    <div id="notifications" class="dropdown">
      <%= render current_user.notifications.recent %>
    </div>
  </div>
</nav>

When a notification is created anywhere in the system, the user's navbar updates instantly without polling.

Performance Optimization Patterns

Production applications benefit from several optimization techniques documented in the Rails 8 production guide.

Eager Loading Stream Subscriptions

Avoid N+1 subscription patterns in lists:

ruby
# app/controllers/projects_controller.rb
def index
  @projects = current_user.projects.includes(:tasks)
end
erb
<!-- Subscribe once per project, not per task -->
<% @projects.each do |project| %>
  <%= turbo_stream_from project %>
  <%= render project %>
<% end %>

Selective Broadcasting

Limit broadcasts to relevant changes:

ruby
# app/models/post.rb
class Post < ApplicationRecord
  broadcasts_refreshes_to ->(post) { post.published? ? :published_posts : :draft_posts }
end

Caching with Morphing

Fragment caching works alongside morphing when cache keys incorporate timestamps:

erb
<% cache [@conversation, @conversation.messages.maximum(:updated_at)] do %>
  <%= render @conversation.messages %>
<% end %>

Common Pitfalls and Solutions

Several issues frequently arise when implementing Hotwire applications.

JavaScript Initialization: Stimulus controllers must account for morphing. Use connect() and disconnect() callbacks properly, and avoid storing state that morphing would invalidate.

app/javascript/controllers/chart_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.chart = new Chart(this.element, this.chartConfig)
  }
  
  disconnect() {
    this.chart?.destroy()
  }
  
  get chartConfig() {
    return JSON.parse(this.element.dataset.chartConfig)
  }
}

Form State Preservation: Morphing preserves form inputs by default. For forms that should reset after submission, clear them explicitly in the Turbo Stream response:

erb
<%= turbo_stream.replace "message_form" do %>
  <%= render "form", message: Message.new %>
<% end %>

Scroll Position Jumps: The scroll: :preserve option helps, but complex layouts may need explicit scroll anchoring:

erb
<div data-turbo-scroll="preserve" id="message_list">
  <%= render @messages %>
</div>

Stimulus Integration for Enhanced Interactivity

While Turbo handles most interactions, Stimulus fills gaps requiring client-side logic. The combination keeps JavaScript focused and minimal.

app/javascript/controllers/auto_submit_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  
  submit() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.formTarget.requestSubmit()
    }, 300)
  }
}
erb
<div data-controller="auto-submit">
  <%= form_with model: @search, data: { auto_submit_target: "form" } do |f| %>
    <%= f.text_field :query, 
        data: { action: "input->auto-submit#submit" },
        placeholder: "Search..." %>
  <% end %>
</div>

For more advanced patterns, review the caching strategies and Rails testing approaches modules.

Conclusion

  • Turbo Drive provides SPA-like navigation without configuration by intercepting links and forms
  • Turbo Frames scope updates to page regions, enabling inline editing and lazy loading
  • Turbo Streams broadcast DOM changes via seven actions: append, prepend, replace, update, remove, before, after
  • Turbo 8 morphing with broadcasts_refreshes delivers real-time updates using idiomorph for minimal DOM changes
  • Preserve elements across morphs with data-turbo-permanent
  • Use turbo_stream_from in views paired with model callbacks for broadcasting
  • The 500ms default debounce prevents excessive broadcasts during rapid updates
  • Stimulus controllers should use proper connect/disconnect lifecycle methods for morphing compatibility
  • Fragment caching remains effective when cache keys incorporate timestamps

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#rails
#turbo
#hotwire
#real-time
#websockets
#stimulus

Share

Related articles