ruby 95 lines · 3 tabs

Cursor-Paginated Rails API with ETag and Conditional GET Caching

Shared by codesnips Aug 2026
3 tabs
module Paginatable
  extend ActiveSupport::Concern

  Page = Struct.new(:records, :next_cursor, keyword_init: true)

  DEFAULT_LIMIT = 25
  MAX_LIMIT = 100

  def cursor_paginate(scope, limit: nil)
    limit = clamp_limit(limit)
    relation = scope.order(created_at: :asc, id: :asc)
    relation = relation.where("id > ?", decode_cursor) if params[:cursor].present?

    rows = relation.limit(limit + 1).to_a
    has_more = rows.size > limit
    rows = rows.first(limit)

    Page.new(
      records: rows,
      next_cursor: has_more ? encode_cursor(rows.last.id) : nil
    )
  end

  private

  def clamp_limit(limit)
    requested = (limit || params[:limit] || DEFAULT_LIMIT).to_i
    requested.clamp(1, MAX_LIMIT)
  end

  def decode_cursor
    Base64.urlsafe_decode64(params[:cursor]).to_i
  rescue ArgumentError
    0
  end

  def encode_cursor(id)
    Base64.urlsafe_encode64(id.to_s)
  end
end
3 files · ruby Explain with highlit

This snippet shows how a Rails API endpoint can return a cursor-paginated collection while participating correctly in HTTP conditional GET, so that unchanged pages cost almost nothing on the wire. The core idea is that a representation of a page of records has a stable, content-derived fingerprint; if the client already holds that fingerprint, the server can answer 304 Not Modified with an empty body instead of re-serializing and re-sending the whole payload.

The Paginatable concern mixes a cursor_paginate helper into controllers. It decodes a base64 cursor into a record id, applies a stable ordering on (created_at, id), and fetches one extra row so it can tell whether a next_cursor exists without a second count query. Keyset pagination is used deliberately instead of OFFSET, because offset pagination degrades on deep pages and can skip or duplicate rows when the underlying data shifts between requests. The returned Page struct carries the records plus the encoded cursor.

The Api::ArticlesController ties it together. In index it calls cursor_paginate on a scoped relation, then builds a weak validator with collection_etag. That etag is derived from the number of records, the maximum updated_at, and the cursor — a cheap composite that changes whenever the page's contents or position change. Crucially the max(updated_at) and count are computed with a single pluck, avoiding an extra round trip.

The conditional GET itself is handled by Rails' stale? method. When stale? returns false, Rails has already matched the incoming If-None-Match header against the etag and rendered 304, so the block that serializes JSON is skipped entirely. public: false keeps the response private to the authenticated user, and last_modified gives HTTP-date based validation as a fallback for clients that send If-Modified-Since.

The articles serializer produces deterministic JSON; determinism matters because the etag is only meaningful if identical data always yields an identical body. A subtle pitfall is that any nondeterministic field (a timestamp of 'now', random ordering) would break caching by changing the etag every request. This pattern suits read-heavy list endpoints where clients poll frequently, trading a little server CPU on the validator for large savings in bandwidth and serialization work.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Paginated Rails API with ETag and Conditional GET Caching — share card
Link copied