Large Rails applications often suffer from slow initial page loads. A dashboard might query dozens of tables, render multiple charts, and fetch data that users may never scroll down to see. Lazy loading solves this by deferring expensive content until users actually need it.
Turbo Frames in Rails 8 include a built-in lazy loading feature that requires minimal code changes. Content loads automatically when it enters the viewport or on page load—without writing custom JavaScript.
How Turbo Frame Lazy Loading Works
A Turbo Frame with the loading="lazy" attribute delays its source request until the frame becomes visible in the viewport. This differs from eager loading (the default), which fetches the frame's content immediately on page load.
The mechanism relies on the Intersection Observer API under the hood. When the frame scrolls into view, Turbo automatically makes a request to the URL specified in the src attribute and replaces the frame's content with the response.
Basic Implementation
Consider a dashboard with multiple widgets. Without lazy loading, every widget loads on the initial request. Here's how to defer the expensive ones:
# app/views/dashboards/show.html.erb
<h1>Dashboard</h1>
<!-- This loads immediately -->
<section class="quick-stats">
<%= render @quick_stats %>
</section>
<!-- These load when scrolled into view -->
<%= turbo_frame_tag "recent_orders",
src: dashboard_recent_orders_path,
loading: :lazy do %>
<div class="loading-placeholder">
<p>Loading recent orders...</p>
</div>
<% end %>
<%= turbo_frame_tag "sales_chart",
src: dashboard_sales_chart_path,
loading: :lazy do %>
<div class="loading-placeholder">
<%= render "shared/chart_skeleton" %>
</div>
<% end %>
<%= turbo_frame_tag "inventory_alerts",
src: dashboard_inventory_alerts_path,
loading: :lazy do %>
<div class="loading-placeholder">
<p>Checking inventory...</p>
</div>
<% end %>
The content inside each turbo_frame_tag block serves as a placeholder until the actual content loads. Users see immediate feedback while expensive queries run in the background.
Controller Setup for Lazy Frames
Each lazy-loaded frame needs a dedicated controller action that returns content wrapped in a matching Turbo Frame:
# app/controllers/dashboards_controller.rb
class DashboardsController < ApplicationController
def show
# Only load essential data for initial render
@quick_stats = QuickStats.new(current_user)
end
def recent_orders
@orders = current_user
.orders
.includes(:line_items, :customer)
.order(created_at: :desc)
.limit(10)
render partial: "recent_orders", locals: { orders: @orders }
end
def sales_chart
@sales_data = SalesReport.new(
user: current_user,
period: 30.days
).daily_totals
render partial: "sales_chart", locals: { sales_data: @sales_data }
end
def inventory_alerts
@alerts = InventoryAlert
.for_user(current_user)
.critical_first
.limit(5)
render partial: "inventory_alerts", locals: { alerts: @alerts }
end
end
The partial must wrap its content in a Turbo Frame with a matching ID:
# app/views/dashboards/_recent_orders.html.erb
<%= turbo_frame_tag "recent_orders" do %>
<div class="orders-widget">
<h2>Recent Orders</h2>
<% if orders.any? %>
<ul class="orders-list">
<% orders.each do |order| %>
<li>
<%= link_to order.number, order_path(order) %>
<span class="amount"><%= number_to_currency(order.total) %></span>
<span class="date"><%= time_ago_in_words(order.created_at) %> ago</span>
</li>
<% end %>
</ul>
<% else %>
<p class="empty-state">No recent orders</p>
<% end %>
</div>
<% end %>
Routes Configuration
Add routes for each lazy-loaded frame endpoint:
# config/routes.rb
Rails.application.routes.draw do
resource :dashboard, only: [:show] do
get :recent_orders
get :sales_chart
get :inventory_alerts
end
end
Handling Loading States with CSS
Turbo adds the aria-busy="true" attribute to frames while loading. Use this for styling loading states:
/* app/assets/stylesheets/turbo_frames.css */
turbo-frame {
display: block;
}
turbo-frame[aria-busy="true"] {
opacity: 0.6;
pointer-events: none;
}
turbo-frame[aria-busy="true"] .loading-placeholder {
display: block;
}
turbo-frame:not([aria-busy]) .loading-placeholder {
display: none;
}
/* Skeleton animation for placeholders */
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Eager Lazy Loading: Load on Page Load
Sometimes content should load immediately but asynchronously—not blocking the initial HTML response. Omit the loading: :lazy attribute to achieve this:
# app/views/posts/show.html.erb
<article>
<h1><%= @post.title %></h1>
<%= @post.body %>
</article>
<!-- Load immediately after page renders, but async -->
<%= turbo_frame_tag "comments", src: post_comments_path(@post) do %>
<p>Loading comments...</p>
<% end %>
<!-- Load only when scrolled into view -->
<%= turbo_frame_tag "related_posts",
src: related_posts_path(@post),
loading: :lazy do %>
<p>Loading related posts...</p>
<% end %>
Error Handling
When a lazy frame fails to load, Turbo dispatches a turbo:frame-missing event. Handle failures gracefully with a Stimulus controller:
// app/javascript/controllers/lazy_frame_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { retryUrl: String }
connect() {
this.element.addEventListener("turbo:frame-missing", this.handleError.bind(this))
}
handleError(event) {
event.preventDefault()
this.element.innerHTML = `
<div class="frame-error">
<p>Failed to load content.</p>
<button data-action="click->lazy-frame#retry">Try Again</button>
</div>
`
}
retry() {
this.element.src = this.retryUrlValue || this.element.src
}
}
Apply the controller to frames that need error handling:
<%= turbo_frame_tag "fragile_content",
src: fragile_content_path,
loading: :lazy,
data: {
controller: "lazy-frame",
lazy_frame_retry_url_value: fragile_content_path
} do %>
<p>Loading...</p>
<% end %>
Common Mistakes to Avoid
Mismatched frame IDs: The frame ID in the source page must exactly match the frame ID in the response. A mismatch causes Turbo to ignore the response entirely.
Missing frame wrapper in partials: Every partial rendered for a lazy frame must include the turbo_frame_tag wrapper with the matching ID.
Lazy loading above the fold: Content visible on initial page load should not use lazy loading—it adds unnecessary latency for content users see immediately.
Too many lazy frames: Each lazy frame creates a separate HTTP request. Consolidate related data into single frames rather than creating dozens of tiny requests.
Summary
Turbo Frame lazy loading provides a zero-JavaScript solution for deferring expensive content. The pattern works best for content below the fold: comment sections, related items, activity feeds, and dashboard widgets. Initial page loads become faster because the server only processes what users see immediately, while background content loads progressively as users scroll.
For pages with multiple expensive queries, lazy loading can transform a slow, blocking experience into a snappy interface that loads perceived-critical content first.