Rails 8 Lazy Loading with Turbo

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.

11 claps
← Back to Blog