Geared Pagination

Most pagination schemes use a fixed page size. Page 1 returns as many elements as page 2. But that's frequently not the most sensible way to page through a large recordset when you care about serving the initial request as quickly as possible. This is particularly the case when using the pagination scheme in combination with an infinite scrolling UI.

Geared Pagination allows you to define different ratios. By default, we will return 15 elements on page 1, 30 on page 2, 50 on page 3, and 100 from page 4 and forward. This has proven to be a very sensible set of ratios for much of the Basecamp UIs. But you can of course tweak the ratios, use fewer, or even none at all, if a certain page calls for a fixed-rate scheme.

On JSON actions that set a page, we'll also automatically set Link and X-Total-Count headers for APIs to be able to page through a recordset.

Example

class MessagesController < ApplicationController
  def index
    set_page_and_extract_portion_from Message.order(created_at: :desc)
  end
end

# app/views/messages/index.html.erb

Showing page <%= @page.number %> of <%= @page.recordset.page_count %> (<%= @page.recordset.records_count %> total messages):

<%= render @page.records %>

<% if @page.last? %>
  No more pages!
<% else %>
  <%= link_to "Next page", messages_path(page: @page.next_param) %>
<% end %>

Cursor-based pagination

By default, Geared Pagination uses offset-based pagination: the page query parameter contains the page number. Each page’s records are located using a query with an OFFSET clause, like so:

SELECT *
FROM messages
ORDER BY created_at DESC
LIMIT 30
OFFSET 15

You may prefer to use cursor-based pagination instead. In cursor-based pagination, the page parameter contains a “cursor” describing the last row of the previous page. Each page’s records are located using a query with conditions that only match records after the previous page. For example, if the last record on the previous page had a created_at value of 2019-01-24T12:35:26.381Z and an ID of 7354857, the current page’s records would be found with a query like this one:

SELECT *
FROM messages
WHERE (created_at = '2019-01-24T12:35:26.381Z' AND id < 7354857)
OR created_at < '2019-01-24T12:35:26.381Z'
ORDER BY created_at DESC, id DESC
LIMIT 30

Geared Pagination supports cursor-based pagination. To use it, pass the :ordered_by option to set_page_and_extract_portion_from in your controllers. Provide the orders to apply to the paginated relation:

set_page_and_extract_portion_from Message.all, ordered_by: { created_at: :desc, id: :desc }

Geared Pagination uses the ordered attributes (in the above example, created_at and id) to generate cursors:

<%= link_to "Next page", messages_path(page: @page.next_param) %>
<!-- <a href="/messages?page=eyJwYWdlX251...">Next page</a> -->

Cursors encode the information Geared Pagination needs to query for the corresponding page’s records: the page number for choosing a page size, and the values of each of the ordered attributes (created_at and id).

When should I use cursor-based pagination?

Cursor-based pagination can outperform offset-based pagination when paginating deeply into a large number of records. DBs commonly execute queries with OFFSET clauses by counting past OFFSET records one at a time, so each page in offset-based pagination takes slightly longer to load than the last. With cursor-based pagination and an appropriate index, the DB can jump directly to the beginning of each page without scanning.

The tradeoff is that Geared Pagination only supports cursor-based pagination on simple relations with simple, column-only orders. Cursor-based pagination also won’t perform better than offset-based pagination without an ordered index. Stick with offset-based pagination if:

  • You need complex ordering on a complex relation
  • You’re paginating a small and/or bounded number of records

Caching

To account for the current page in fragment caches, include the @page directly. That includes the current page number and gear ratios.

Fragment caching a message's comments:

<% cache [ @message, @page ] do %>
  <%= render @page.records %>
<% end %>

NOTE: The page does not include cache keys for all the records. That would require loading all the records, defeating the purpose of using the cache. Use a parent record, like a message that's touched when new comments are posted, as the cache key instead.

ETags

When a controller action sets an ETag and uses geared pagination, the current page and gear ratios are automatically included in the ETag.

License

Geared Pagination is released under the MIT License.