Offset based pagination in GraphQL-ruby

Offset-based pagination is a straightforward pagination method that uses limit and offset parameters to fetch subsets of data. While simpler than cursor-based pagination, it's useful for many use cases.

Basic Implementation

Add Arguments to Query

Add limit and offset arguments:

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :posts, [Types::PostType], null: false do
      argument :limit, Integer, required: false, default_value: 20
      argument :offset, Integer, required: false, default_value: 0
    end

    def posts(limit: 20, offset: 0)
      Post.order(created_at: :desc)
        .limit(limit)
        .offset(offset)
    end
  end
end

Query Example

query GetPosts($limit: Int, $offset: Int) {
  posts(limit: $limit, offset: $offset) {
    id
    title
    content
    createdAt
  }
}

Input Object Pattern

Create Input Type

Use input objects for cleaner API:

# app/graphql/types/pagination_input.rb
module Types
  class PaginationInput < Types::BaseInputObject
    argument :limit, Integer, required: false, default_value: 20
    argument :offset, Integer, required: false, default_value: 0
  end
end

Use in Query

field :posts, [Types::PostType], null: false do
  argument :pagination, Types::PaginationInput, required: false
end

def posts(pagination: { limit: 20, offset: 0 })
  limit = pagination[:limit] || 20
  offset = pagination[:offset] || 0

  Post.order(created_at: :desc)
    .limit(limit)
    .offset(offset)
end

Query with Input Object

query GetPosts($pagination: PaginationInput) {
  posts(pagination: $pagination) {
    id
    title
    content
  }
}

Adding Page Info

Return Pagination Metadata

Include pagination information in the response:

# app/graphql/types/post_connection_type.rb
module Types
  class PostConnectionType < Types::BaseObject
    field :posts, [Types::PostType], null: false
    field :total_count, Integer, null: false
    field :has_next_page, Boolean, null: false
    field :has_previous_page, Boolean, null: false
    field :current_page, Integer, null: false
    field :total_pages, Integer, null: false
  end
end

Implement Resolver

field :posts, Types::PostConnectionType, null: false do
  argument :limit, Integer, required: false, default_value: 20
  argument :offset, Integer, required: false, default_value: 0
end

def posts(limit: 20, offset: 0)
  total_count = Post.count
  posts = Post.order(created_at: :desc)
    .limit(limit)
    .offset(offset)

  current_page = (offset / limit) + 1
  total_pages = (total_count.to_f / limit).ceil

  {
    posts: posts,
    total_count: total_count,
    has_next_page: (offset + limit) < total_count,
    has_previous_page: offset > 0,
    current_page: current_page,
    total_pages: total_pages
  }
end

Query with Page Info

query GetPosts($limit: Int, $offset: Int) {
  posts(limit: $limit, offset: $offset) {
    posts {
      id
      title
      content
    }
    totalCount
    hasNextPage
    hasPreviousPage
    currentPage
    totalPages
  }
}

Real-World Example

With Filtering

Combine pagination with filtering:

field :posts, Types::PostConnectionType, null: false do
  argument :limit, Integer, required: false, default_value: 20
  argument :offset, Integer, required: false, default_value: 0
  argument :status, String, required: false
  argument :category, String, required: false
end

def posts(limit: 20, offset: 0, status: nil, category: nil)
  relation = Post.order(created_at: :desc)
  relation = relation.where(status: status) if status
  relation = relation.joins(:category).where(categories: { name: category }) if category

  total_count = relation.count
  posts = relation.limit(limit).offset(offset)

  {
    posts: posts,
    total_count: total_count,
    has_next_page: (offset + limit) < total_count,
    has_previous_page: offset > 0,
    current_page: (offset / limit) + 1,
    total_pages: (total_count.to_f / limit).ceil
  }
end

Advantages

Offset-based pagination is simple and intuitive:

  • Easy to implement: Straightforward limit/offset logic
  • Predictable: Easy to calculate page numbers
  • User-friendly: Familiar pagination UI (page 1, 2, 3...)
  • Good for small datasets: Works well when data doesn't change frequently

Limitations

Be aware of the limitations:

  • Performance: Degrades with large offsets
  • Inconsistency: Can skip or duplicate items if data changes
  • Not scalable: Poor performance at high offsets

When to Use

Use offset-based pagination when:

  • Dataset is relatively small
  • Data doesn't change frequently
  • You need page numbers for UI
  • Simplicity is more important than performance

Best Practices

  1. Set reasonable limits: Default to 20-50 items per page
  2. Validate inputs: Ensure limit and offset are positive
  3. Add indexes: Index columns used in ordering
  4. Consider cursor-based: For large or frequently changing data

Input Validation

Validate pagination parameters:

def posts(limit: 20, offset: 0)
  limit = [limit.to_i, 100].min.clamp(1, 100) # Max 100 items
  offset = [offset.to_i, 0].max # No negative offsets

  Post.order(created_at: :desc)
    .limit(limit)
    .offset(offset)
end

Comparison with Cursor-Based

FeatureOffset-BasedCursor-Based
SimplicityVery simpleMore complex
PerformanceGood for small offsetsBetter overall
ConsistencyCan have issuesMore consistent
UIPage numbersInfinite scroll

Conclusion

Offset-based pagination in GraphQL-ruby provides a simple, intuitive way to paginate data. While it has limitations with large datasets, it's perfect for many use cases where simplicity and familiarity are more important than maximum performance. For large or frequently changing datasets, consider cursor-based pagination instead.

Offset based pagination in GraphQL-ruby - Abhay Nikam