Hotwire and Turbo in Rails: Production Patterns Guide
Replace React with Hotwire and Turbo in Rails. Production patterns for Turbo Frames, Streams, and Stimulus with performance comparisons and migration results.
Migrating a production dashboard from React to Hotwire cut our frontend code by 94% - from 3,200 lines of JavaScript to 180 lines of Stimulus controllers. In my previous post about Rails 8, I mentioned that Hotwire deserves its own deep dive. Here is what I’ve learned building real-time interfaces with it.
| Feature | Hotwire + Turbo | React SPA | Vue + Inertia |
|---|---|---|---|
| Frontend bundle size | ~28kb | 150-400kb | 80-200kb |
| Build tooling required | None | Webpack/Vite + config | Vite + config |
| State management | Server-side (Rails) | Redux/Zustand/Context | Pinia/Vuex |
| Real-time updates | Built-in (Turbo Streams) | Requires extra libraries | Requires extra libraries |
| SEO/SSR | Works by default | Needs Next.js or SSR setup | Needs Nuxt or SSR setup |
| Learning curve for Rails devs | Low | High | Medium |
| API layer needed | No | Yes (JSON API) | Optional (Inertia) |
| Team structure | Full-stack | Frontend + Backend | Full-stack possible |
The Hotwire Philosophy
Hotwire sends HTML from the server instead of JSON, eliminating the need for build tools, state management, or a virtual DOM. Your existing Rails knowledge applies directly, and it works without JavaScript enabled.
Core principles:
- HTML over the wire: Server renders HTML, not JSON for client-side rendering
- Progressive enhancement: Works without JavaScript
- Minimal frontend complexity: No Webpack, no Redux, no virtual DOM
- Real Rails code: Controllers, views, and partials - nothing new to learn
For SaaS platforms, admin tools, and e-commerce backends, Hotwire hits the sweet spot of developer productivity and user experience.
The Three Pillars of Hotwire
1. Turbo Drive: Fast Page Navigation
Turbo Drive makes every link click and form submission instant by intercepting them and swapping page content without a full reload. No configuration needed - your existing Rails app feels like a SPA immediately.
<!-- Regular Rails link -->
<%= link_to "Dashboard", dashboard_path %>
<!-- Turbo Drive automatically makes this a fast navigation -->
<!-- No page reload, instant transition -->
2. Turbo Frames: Scoped Updates
Turbo Frames update specific parts of the page without full refreshes. Wrap any section in a turbo_frame_tag, and clicks within that frame only replace that section - the rest of the page stays untouched.
<!-- app/views/transactions/index.html.erb -->
<div class="page-header">
<h1>Transactions</h1>
</div>
<%= turbo_frame_tag "transactions_list" do %>
<%= render @transactions %>
<%= paginate @transactions %>
<% end %>
<aside class="sidebar">
<%= render "filters" %>
</aside>
When a user clicks pagination inside the frame, only the transactions_list frame updates. The header and sidebar stay untouched.
3. Turbo Streams: Real-time Updates
Turbo Streams push real-time DOM updates from the server via WebSocket, SSE, or HTTP responses. They support multiple operations - append, prepend, replace, update, remove - across different page elements in a single response.
# app/controllers/transactions_controller.rb
def create
@transaction = Transaction.create(transaction_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to transactions_path }
end
end
<!-- app/views/transactions/create.turbo_stream.erb -->
<%= turbo_stream.prepend "transactions_list", @transaction %>
<%= turbo_stream.update "balance", partial: "shared/balance" %>
<%= turbo_stream.replace "notification", partial: "shared/success" %>
When a transaction is created, this:
- Prepends it to the transactions list
- Updates the account balance
- Shows a success notification
All without writing a single line of JavaScript.
Production Example: Real-Time Trading Dashboard
Here’s how to build a real-time trading dashboard. Requirements:
- Live price updates every 5 seconds
- Real-time order execution
- Portfolio balance updates
- Transaction history
Setup: The Layout
<!-- app/views/dashboards/show.html.erb -->
<div class="dashboard-grid">
<!-- Live Prices -->
<%= turbo_frame_tag "live_prices",
src: live_prices_path,
refresh: "every 5s" do %>
<%= render "loading_prices" %>
<% end %>
<!-- Portfolio Balance -->
<%= turbo_frame_tag "portfolio_balance",
src: portfolio_balance_path do %>
<%= render @portfolio %>
<% end %>
<!-- Order Form -->
<%= turbo_frame_tag "order_form" do %>
<%= render "orders/form" %>
<% end %>
<!-- Recent Transactions -->
<%= turbo_stream_from "user_#{current_user.id}_transactions" %>
<%= turbo_frame_tag "recent_transactions" do %>
<%= render @recent_transactions %>
<% end %>
</div>
Live Price Updates
# app/controllers/live_prices_controller.rb
class LivePricesController < ApplicationController
def show
@prices = PriceService.current_prices(symbols: params[:symbols])
# Turbo Frame automatically handles the refresh
render turbo_frame: "live_prices"
end
end
<!-- app/views/live_prices/show.html.erb -->
<%= turbo_frame_tag "live_prices" do %>
<div class="prices-grid">
<% @prices.each do |symbol, price| %>
<div class="price-card" data-symbol="<%= symbol %>">
<span class="symbol"><%= symbol %></span>
<span class="price <%= price_change_class(price) %>">
$<%= number_with_precision(price.current, precision: 2) %>
</span>
<span class="change">
<%= price.change_percent %>%
</span>
</div>
<% end %>
</div>
<p class="text-xs text-gray-500">
Updated <%= time_ago_in_words(Time.current) %> ago
</p>
<% end %>
Result: Prices update every 5 seconds automatically. No WebSocket complexity, no JavaScript. Just HTML.
Real-time Order Execution
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
@order = current_user.orders.build(order_params)
if @order.save
# Process order asynchronously
OrderExecutionJob.perform_later(@order.id)
respond_to do |format|
format.turbo_stream {
render turbo_stream: [
turbo_stream.replace("order_form", partial: "orders/form", locals: { order: Order.new }),
turbo_stream.prepend("pending_orders", partial: "orders/order", locals: { order: @order }),
turbo_stream.update("flash", partial: "shared/success", locals: { message: "Order placed successfully" })
]
}
end
else
respond_to do |format|
format.turbo_stream {
render turbo_stream: turbo_stream.replace("order_form", partial: "orders/form", locals: { order: @order })
}
end
end
end
end
Broadcasting Updates from Background Jobs
# app/jobs/order_execution_job.rb
class OrderExecutionJob < ApplicationJob
def perform(order_id)
order = Order.find(order_id)
# Execute order with external API
result = TradingAPI.execute(order)
if result.success?
order.update!(status: :executed, executed_at: Time.current)
# Broadcast updates to user's dashboard
broadcast_order_execution(order)
else
order.update!(status: :failed)
broadcast_order_failure(order)
end
end
private
def broadcast_order_execution(order)
# Update multiple parts of the UI
Turbo::StreamsChannel.broadcast_replace_to(
"user_#{order.user_id}_transactions",
target: "order_#{order.id}",
partial: "orders/executed_order",
locals: { order: order }
)
# Update portfolio balance
Turbo::StreamsChannel.broadcast_update_to(
"user_#{order.user_id}_portfolio",
target: "portfolio_balance",
partial: "dashboards/portfolio_balance",
locals: { portfolio: order.user.portfolio }
)
# Show notification
Turbo::StreamsChannel.broadcast_append_to(
"user_#{order.user_id}_notifications",
target: "notifications",
partial: "shared/notification",
locals: { message: "Order executed: #{order.symbol} #{order.quantity} @ #{order.executed_price}" }
)
end
end
Result: When an order executes (async in background), the UI updates in real-time:
- Order moves from “pending” to “executed”
- Portfolio balance updates
- Notification appears
All of this happens automatically for any user viewing their dashboard.
Advanced Pattern: Inline Editing
Turbo Frames enable inline editing with zero JavaScript - clicking “Edit” swaps a display row with a form, and saving swaps it back. No page navigation, no modal, no custom JavaScript.
<!-- app/views/transactions/_transaction.html.erb -->
<%= turbo_frame_tag dom_id(transaction) do %>
<div class="transaction-row">
<div class="transaction-details">
<span class="date"><%= transaction.date.strftime("%b %d, %Y") %></span>
<span class="description"><%= transaction.description %></span>
<span class="amount"><%= number_to_currency(transaction.amount) %></span>
</div>
<div class="actions">
<%= link_to "Edit", edit_transaction_path(transaction), class: "btn-sm" %>
</div>
</div>
<% end %>
<!-- app/views/transactions/edit.html.erb -->
<%= turbo_frame_tag dom_id(@transaction) do %>
<%= form_with model: @transaction do |f| %>
<div class="inline-edit-form">
<%= f.text_field :description, class: "form-input" %>
<%= f.text_field :amount, class: "form-input" %>
<div class="actions">
<%= f.submit "Save", class: "btn-primary" %>
<%= link_to "Cancel", transaction_path(@transaction), class: "btn-secondary" %>
</div>
</div>
<% end %>
<% end %>
What happens:
- User clicks “Edit”
- Turbo Frame loads
edit_transaction_path - Form appears inline replacing the transaction row
- User saves → row updates with new data
- All other transactions stay untouched
Zero JavaScript. Just Rails conventions.
Adding Interactivity with Stimulus
Stimulus handles the 5-10% of interactions that genuinely need client-side JavaScript - dropdowns, modals, debounced inputs, and client-side validation. It keeps JavaScript organized in small controllers tied to HTML elements via data attributes.
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
toggle() {
this.menuTarget.classList.toggle("hidden")
}
hide(event) {
if (!this.element.contains(event.target)) {
this.menuTarget.classList.add("hidden")
}
}
}
<!-- app/views/shared/_user_menu.html.erb -->
<div data-controller="dropdown" data-action="click@window->dropdown#hide">
<button data-action="click->dropdown#toggle" class="user-avatar">
<%= current_user.avatar %>
</button>
<div data-dropdown-target="menu" class="dropdown-menu hidden">
<%= link_to "Profile", profile_path %>
<%= link_to "Settings", settings_path %>
<%= link_to "Logout", logout_path, data: { turbo_method: :delete } %>
</div>
</div>
Stimulus philosophy: Sprinkle JavaScript where needed, not everywhere.
Performance Considerations
1. Frame Eager Loading
Preload Turbo Frame content on hover to make navigation feel instant. When the user clicks, Rails has already loaded the content in the background:
<%= link_to "View Details",
transaction_path(@transaction),
data: { turbo_frame: "modal", turbo_prefetch: true } %>
2. Lazy Loading Frames
Defer non-critical content by lazy loading frames - content loads only when scrolled into view, reducing initial page load time:
<%= turbo_frame_tag "analytics",
src: analytics_path,
loading: :lazy do %>
<!-- Shows while loading -->
<%= render "loading_skeleton" %>
<% end %>
3. Debouncing Searches
Combine Stimulus with Turbo Frames for real-time search that updates as you type. A 300ms debounce prevents excessive requests while keeping the interface responsive:
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "results"]
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit()
}, 300)
}
}
<%= form_with url: search_path,
method: :get,
data: {
controller: "search",
turbo_frame: "search_results",
search_target: "form"
} do |f| %>
<%= f.search_field :q,
data: { action: "input->search#search" },
placeholder: "Search transactions..." %>
<% end %>
<%= turbo_frame_tag "search_results" do %>
<!-- Results appear here -->
<% end %>
Result: Search updates as you type (with 300ms debounce), results appear inline.
Testing Hotwire Features
Turbo Frames and Streams work seamlessly with Rails system tests - no complicated async mocking or special configuration required. Tests read like user interactions:
# test/system/transactions_test.rb
class TransactionsTest < ApplicationSystemTestCase
test "creating a transaction updates the list" do
visit transactions_path
click_on "New Transaction"
within "#new_transaction" do
fill_in "Description", with: "Coffee"
fill_in "Amount", with: "5.50"
click_on "Create"
end
# Turbo Stream automatically updates the list
assert_selector "#transactions_list", text: "Coffee"
assert_selector "#transactions_list", text: "$5.50"
# Balance updated
assert_selector "#balance", text: "$494.50" # assuming starting balance
end
test "editing a transaction inline" do
transaction = transactions(:one)
visit transactions_path
within dom_id(transaction) do
click_on "Edit"
fill_in "Description", with: "Updated description"
click_on "Save"
# Frame updates inline
assert_text "Updated description"
end
end
end
When NOT to Use Hotwire
Avoid Hotwire for highly interactive applications requiring complex drag-and-drop, canvas manipulation, or real-time collaboration. Consider alternatives for:
- Highly Interactive Apps: Complex drag-and-drop, canvas manipulation, or real-time collaboration might need more JavaScript
- Mobile Apps: Native mobile apps need native code, not HTML over the wire (though Turbo Native helps)
- Offline-First: Apps requiring offline functionality need more client-side logic
- Existing React Codebase: Migrating working React apps is usually not worth it
Production Results
Migrating a production dashboard from React to Hotwire cut frontend code by 94%, eliminated the API layer entirely, and reduced the development team from 3 specialists to 2 full-stack developers. Here are the detailed numbers:
Before (React + Rails API):
- Frontend bundle: 340kb (gzipped)
- Build time: 45 seconds
- Lines of JavaScript: ~3,200
- API endpoints: 24
- Development team: 2 frontend + 1 backend
After (Hotwire):
- Frontend bundle: 28kb (Hotwire + Stimulus)
- Build time: 0 seconds (no build)
- Lines of JavaScript: ~180 (Stimulus controllers)
- API endpoints: 0 (just Rails views)
- Development team: 2 full-stack developers
User Experience:
- Faster initial load (smaller bundle)
- Snappier interactions (HTML is faster than JSON → render)
- Same real-time feel as before
Developer Experience:
- 60% reduction in total code
- Bugs reduced by half (less JavaScript = less bugs)
- New features ship 40% faster
- No frontend/backend coordination needed
Getting Started Checklist
Want to build with Hotwire? Here’s your path:
1. Setup (Rails 8+)
# Already included in Rails 8
# For Rails 7, add:
bundle add hotwire-rails
rails hotwire:install
2. Start Simple
- Replace one full page reload with Turbo Frame
- Add one Turbo Stream action
- Build confidence incrementally
3. Common Patterns to Learn
- Inline editing with Turbo Frames
- Live search with debouncing
- Real-time updates with broadcasts
- Modal dialogs with Turbo Frames
4. Resources
The Bottom Line
Hotwire trades frontend flexibility for speed of delivery - and for most Rails apps, that trade pays off.
For most web applications - SaaS products, admin panels, internal tools, e-commerce - Hotwire delivers:
- Faster development: Less code, less complexity
- Better performance: Smaller bundles, faster interactions
- Easier maintenance: One language, one framework
- Great UX: Real-time updates without sacrificing simplicity
Is it as flexible as React? No. Do you need React’s flexibility for 90% of applications? Also no.
What’s Next?
For the database side of performance, my post on database optimization techniques for Rails covers indexing strategies, query optimization, and N+1 elimination patterns that pair well with Hotwire’s server-rendered approach.
Need help building reactive interfaces with Hotwire? I help teams adopt Hotwire patterns and migrate from heavy frontend frameworks. Reach out at nikita.sinenko@gmail.com.