Turbo Lazy-Loading Scroll — How 37Signals do it

Inside Campfire, I prompted cursor "How do we do infinite loading in the account users display?" Here's the result:

The infinite loading for account users is implemented using Turbo Streams and lazy-loading Turbo Frames with the geared_pagination gem. Here's the complete flow:

1. Controller Setup (app/controllers/accounts/users_controller.rb)

def index
  set_page_and_extract_portion_from User.active.ordered, per_page: 500
end

The set_page_and_extract_portion_from method comes from the geared_pagination gem (version 1.2.0) and creates a @page object with:

  • @page.records - the current batch of users
  • @page.last? - whether this is the last page
  • @page.next_param - the parameter for the next page

2. Initial Display (app/views/accounts/edit.html.erb)

<turbo-frame id="account_users">
  <%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
  <%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
</turbo-frame>

This renders:

  • The first batch of users (up to 500 per page)
  • A "next page container" if there are more users to load

3. Lazy Loading Container (app/views/accounts/users/_next_page_container.html.erb)

<%= turbo_frame_tag :next_page_container, loading: :lazy,
      src: account_users_path(page: page, format: :turbo_stream), class: "flex center" do %>
  <div class="spinner center"></div>
<% end %>

This creates a lazy-loading Turbo Frame that:

  • Shows a spinner initially
  • Automatically loads when it comes into the viewport (loading: :lazy)
  • Makes a request to account_users_path with the next page parameter
  • Requests the response in turbo_stream format

4. Turbo Stream Response (app/views/accounts/users/index.turbo_stream.erb)

<%= turbo_stream.replace :next_page_container, partial: "accounts/users/user", collection: @page.records, as: :user %>

<% unless @page.last? %>
  <%= turbo_stream.append :account_users, partial: "accounts/users/next_page_container", locals: { page: @page.next_param } %>
<% end %>

When the lazy frame loads, this:

  1. Replaces the spinner with the new batch of users
  2. Appends a new "next page container" to the bottom (if there are more pages)

5. How the Infinite Loading Works

  1. Initial Load: First 500 users are displayed with a lazy-loading frame at the bottom
  2. Scroll Trigger: When user scrolls near the bottom, the lazy frame enters the viewport
  3. Automatic Request: Turbo automatically makes a request for the next page
  4. Stream Update: The response replaces the spinner with new users and adds another lazy frame
  5. Repeat: Process continues until all users are loaded (@page.last? is true)

Key Benefits of This Approach:

  • No [Extra] JavaScript Required: Uses native browser intersection observer through Turbo's lazy loading [This isn't quite right. Turbo streams uses Javascript. But there definitely is the benefit that you don't have to write any custom javascript]
  • Seamless UX: Users see a spinner briefly, then new content appears automatically
  • Performance: Large page size (500 users) reduces the number of requests
  • Progressive Enhancement: Works without JavaScript (would fall back to regular pagination)
  • Accessibility: Maintains proper focus management and screen reader compatibility

This implementation is elegant because it leverages Turbo's built-in lazy loading capabilities rather than requiring custom JavaScript for scroll detection and AJAX requests.

Subscribe to Ori Marash

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe