Turbo Frames with Lazy Loading: Defer Slow Page Fragments Until After Paint
Learn how to use Hotwired Turbo's lazy‑loading frames to postpone fetching a slow widget until after the main page has painted, how the server must respond, and how to verify the behavior in Rails 7 or standalone Turbo.
13 Jul 2025, 09:14 UTC

The Problem: Slow Widgets Blocking the First Paint
Modern dashboards often bundle a handful of widgets that each hit the database or an external API. When the main page loads, the browser has to wait for all of those requests before it can paint the user’s view. A single slow fragment can make the entire page feel sluggish, even if the rest of the content is already available.
Turbo Frames Primer
Turbo Frames let you wrap any portion of a page in a <turbo-frame> element. When a link or form inside the frame is activated, only the frame’s content is replaced with the response that matches the frame’s id. The rest of the page stays untouched, which keeps navigation fast and reduces server load.
Example:
<turbo-frame id="comments">
<p>Loading comments…</p>
</turbo-frame>
Lazy Loading Frames: The loading="lazy" Attribute
Turbo ships a loading="lazy" attribute that turns a frame into a deferred fetcher. The browser will only request the frame’s src once the frame intersects the viewport. Until then, the inline content inside the frame is rendered, giving the user an immediate visual cue that something will appear.
Key points:
- IntersectionObserver drives the delay.
- Frames without
loading="lazy"behave like normal frames and fetch immediately. - Deferred frames do not block the initial paint of the page.
Server‑Side Requirements
The server must respond with HTML that contains a <turbo-frame> whose id matches the request’s id. Turbo will reject any response that lacks the matching frame and surface a client‑side error, so the endpoint should render a partial that starts with the frame tag.
Typical Rails 7 response:
def activity
@recent = Activity.recent
render partial: "activity", layout: false
end
And the partial _activity.html.erb:
<turbo-frame id="activity">
<ul>
<% @recent.each do |act| %>
<li><%= act.title %></li>
<% end %>
</ul>
</turbo-frame>
Practical Example: A Deferred Activity Widget
Below is a minimal dashboard page that defers its activity widget until the user scrolls to it. The page loads instantly, then the widget fetches in the background.
<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@7.1.0/dist/turbo.min.js"></script>
</head>
<body>
<h1>Dashboard</h1>
<p>Main content that loads instantly.</p>
<!-- Lazy‑loaded activity widget -->
<turbo-frame id="activity" src="/activity" loading="lazy">
<div class="placeholder">Loading recent activity…</div>
</turbo-frame>
<!-- The rest of the page -->
<p>More content that users can scroll through before the widget appears.</p>
</body>
</html>
When the user first loads the page, the browser paints the heading, the placeholder, and the rest of the content. Only when the activity frame enters the viewport does Turbo issue a GET to /activity and replace the placeholder with the real list.
Trade‑offs & Limitations
- Separate HTTP request: Each lazy frame triggers its own request, so authentication, caching, and error handling must be configured per fragment.
- Server‑side rendering required: The endpoint must return a frame‑shaped HTML snippet; otherwise Turbo will log an error.
- Version check: The
loading="lazy"attribute was added in Turbo 7.x. Verify your Turbo version before using it. - Non‑HTML responses: Redirects or JSON responses break the frame; the endpoint must render pure HTML.
How to Verify the Behavior
- Confirm Turbo version: In a Rails app, run
bundle show turbo-railsor inspectnode_modules/@hotwired/turbo/package.json. In a CDN setup, check the script URL for the version number. - Network throttling: Open DevTools, throttle the network to
Slow 3G, reload the page, and watch that theactivityframe request fires only after the element scrolls into view. - Missing frame id test: Temporarily change the server response to omit the matching
idand observe the console error:Turbo: Expected in the response. - JavaScript disabled: Disable JS and reload. The placeholder content inside the frame should still render because Turbo is progressive.
Actionable Next Steps
- Audit your dashboard widgets for slow endpoints and wrap them in
<turbo-frame>tags. - Add
loading="lazy"to defer those widgets until the user scrolls. - Ensure each endpoint returns a frame‑shaped partial and handles authentication independently.
- Use DevTools to monitor frame requests and confirm they fire at the right time.
- Implement graceful fallback content inside each frame for progressive enhancement.
By deferring heavy fragments with Turbo Frames, you can keep the initial paint snappy, improve perceived performance, and still deliver a fully interactive page when the user needs it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.