GraphQL: Implementing Pagination, Filtering, and Sorting (Part-1)

Shivam DubeyShivam Dubey
13 min read

In modern web applications, effective data management is key to enhancing user experience and ensuring optimal performance. When dealing with large datasets, it becomes especially important to have strategies that allow users to access and interact with the data efficiently. GraphQL Pagination is one of the most powerful techniques for achieving this. It enables developers to break down large sets of data into smaller, more manageable chunks, thus improving query performance and making data navigation more seamless.

In the first part of this article, we will explore the concept of GraphQL Pagination, focusing on how to implement and optimize pagination techniques to fetch and display data across multiple pages. We’ll look at popular methods such as cursor-based pagination, page-based pagination, and others, to ensure that data-heavy applications perform efficiently while maintaining a smooth user experience.

For a complete guide on how to enhance your GraphQL API with filtering and sorting features, check out the second article in this series click here.


Pagination

Pagination splits large datasets into smaller, manageable chunks. This prevents over-fetching (retrieving unnecessary data) and improves application performance. GraphQL supports two common pagination strategies:


1. Offset-Based Pagination

Concept:
Offset-based pagination is one of the most common techniques for breaking up large datasets into smaller, more manageable chunks. This method uses two main parameters:

  • Offset: The index where the data retrieval starts.

  • Limit: The number of records to retrieve starting from the offset.

Scenario: Paginating User Data

Imagine you’re building a social media application, and you want to display a list of users to a client in a paginated format. Here's how you might apply offset-based pagination in a real-world scenario:

Use Case:

You have a dataset of thousands of users, but you don’t want to overwhelm your front-end with too many records at once. Instead, you want to send only a limited number of records per request (say 10 users per page).

Scenario Flow:

  1. Page 1 Request:

    • The client requests the first page of users with an offset of 0 and a limit of 10.

    • The backend calculates:

      • start = 0 (first user).

      • end = 10 (up to the 10th user).

    • The backend returns users 1 through 10, along with the total number of users.

  2. Page 2 Request:

    • The client requests the second page of users with an offset of 10 and a limit of 10.

    • The backend calculates:

      • start = 10 (starting at the 11th user).

      • end = 20 (up to the 20th user).

    • The backend returns users 11 through 20.

  3. Page 3 Request:

    • The client requests the third page of users with an offset of 20 and a limit of 10.

    • The backend calculates:

      • start = 20 (starting at the 21st user).

      • end = 30 (up to the 30th user).

    • The backend returns users 21 through 30.

This continues until the client has fetched all the users.

GraphQL Schema:

type Query {
  paginatedUsers(offset: Int!, limit: Int!): PaginatedUsers!
}

type PaginatedUsers {
  users: [User!]!
  totalCount: Int!
}

type User {
  id: ID!
  name: String!
  age: Int!
}

Explanation:

  • offset: The starting index from which you want to fetch records.

  • limit: The number of records to return.

  • PaginatedUsers: This type contains:

    • A list of users that fits within the specified range (offset and limit).

    • totalCount gives the total number of users, regardless of pagination.

Go Implementation:

type User struct {
    ID   string
    Name string
    Age  int
}

// Mock database of users
var allUsers = []User{
    {ID: "1", Name: "Alice", Age: 25},
    {ID: "2", Name: "Bob", Age: 30},
    {ID: "3", Name: "Charlie", Age: 35},
    {ID: "4", Name: "Diana", Age: 40},
}

// Function to retrieve users with offset and limit
func getPaginatedUsers(offset, limit int) (users []User, totalCount int) {
    totalCount = len(allUsers) // Get total number of users in the database
    start := offset            // Starting index
    end := offset + limit      // Ending index

    // Check if the start index is greater than the total count
    if start > totalCount {
        return []User{}, totalCount // No users to return
    }

    // Ensure that the end index does not exceed the dataset size
    if end > totalCount {
        end = totalCount
    }

    // Fetch the slice of users based on the offset and limit
    users = allUsers[start:end]
    return users, totalCount
}

Code Explanation:

  1. Define the User struct:

    • Represents a user with fields for ID, Name, and Age.
  2. Mock data with allUsers:

    • Simulates a database by storing a list of users.
  3. Implement getPaginatedUsers:

    • Input Parameters:

      • offset: Start index for data retrieval.

      • limit: Maximum number of items to fetch.

    • Process:

      • Calculate the start and end indices for the slice.

      • Adjust the end index if it exceeds the dataset size.

      • If the start exceeds the total count, return an empty list.

    • Output:

      • A slice of users between the start and end indices.

      • The total count of users.

  4. Flow:

    • Assume a request for offset = 1 and limit = 2:

      • Start at index 1 (Bob).

      • Fetch 2 users: Bob and Charlie.

Scenario: Paginating User Data

Imagine you’re building a social media application, and you want to display a list of users to a client in a paginated format. Here's how you might apply offset-based pagination in a real-world scenario:

Use Case:

You have a dataset of thousands of users, but you don’t want to overwhelm your front-end with too many records at once. Instead, you want to send only a limited number of records per request (say 10 users per page).

Scenario Flow:

  1. Page 1 Request:

    • The client requests the first page of users with an offset of 0 and a limit of 10.

    • The backend calculates:

      • start = 0 (first user).

      • end = 10 (up to the 10th user).

    • The backend returns users 1 through 10, along with the total number of users.

  2. Page 2 Request:

    • The client requests the second page of users with an offset of 10 and a limit of 10.

    • The backend calculates:

      • start = 10 (starting at the 11th user).

      • end = 20 (up to the 20th user).

    • The backend returns users 11 through 20.

  3. Page 3 Request:

    • The client requests the third page of users with an offset of 20 and a limit of 10.

    • The backend calculates:

      • start = 20 (starting at the 21st user).

      • end = 30 (up to the 30th user).

    • The backend returns users 21 through 30.

This continues until the client has fetched all the users.


Handling Edge Cases

There are some important considerations when implementing offset-based pagination:

  1. Empty Results:
    If the offset exceeds the total number of records in the dataset, return an empty list with the total count still provided.

    • Example: If you have only 3 users but request an offset of 5, you should return an empty list but still include the total count of users (3).
  2. Out-of-Bounds End Index:
    If the end index exceeds the total number of records, adjust it to match the total size of the dataset.

    • Example: If your dataset contains 5 users and you request an offset of 3 and a limit of 5, the system should return users 3 through 5.

Advantages and Disadvantages of Offset-Based Pagination

Advantages:

  • Simple to Implement: This is a straightforward method for paginating results and is easy to understand.

  • Flexible: You can easily request any page by changing the offset and limit values.

Disadvantages:

  • Performance Issues with Large Datasets:
    As the offset increases, the database has to skip more records, which can lead to slower queries. For example, fetching the 1000th page requires skipping over 10,000 records if each page has 10 results.

  • Inconsistent Results in Dynamic Datasets:
    If users are added or removed while paginating, the results could shift, leading to inconsistencies. For example, a user could appear on two pages or be missed entirely if records are deleted between page requests.


2. Cursor-Based Pagination

Concept:
Cursor-based pagination is a method where, instead of using numeric indices (like offsets), we use a cursor (often a unique identifier, such as a user ID) to determine where the next set of data should be fetched from. This is particularly beneficial for dynamic datasets, where the data may change frequently (e.g., records being added, deleted, or updated). Cursor-based pagination ensures that you always get consistent results, even if the dataset changes between requests.

GraphQL Schema:

# The cursorPaginatedUsers query allows fetching a paginated list of users.
# It accepts two arguments:
# - cursor (optional): A String representing the starting point for pagination.
# - limit (required): An integer that specifies how many users to fetch per request.
type Query {
  cursorPaginatedUsers(cursor: String, limit: Int!): CursorPaginatedUsers!
}

# The CursorPaginatedUsers type defines the structure of the paginated user list.
type CursorPaginatedUsers {
  # A list of User objects representing the users fetched in the current page.
  users: [User!]!

  # A Boolean indicating whether there are more pages of users available to fetch.
  hasNextPage: Boolean!

  # A String representing the cursor value to be used for the next page of results.
  endCursor: String
}

# The User type defines the structure of a user object.
type User {
  id: ID!
  name: String!
  age: Int!
}

Explanation:

  • cursor: A unique identifier (often the ID of the last fetched item) used to know where the last query ended. The next query will start fetching from the next item after this cursor.

  • limit: The number of users to fetch in one query.

  • CursorPaginatedUsers: Contains:

    • A list of users that fit within the provided cursor and limit.

    • hasNextPage: A boolean flag indicating whether more users are available for fetching.

    • endCursor: The ID of the last user in the current set, which serves as the cursor for the next page.


Go Implementation:

// getCursorPaginatedUsers retrieves a paginated list of users based on the provided cursor and limit.
// The cursor is used to determine the starting point for pagination, and the limit specifies how many users to return per page.
func getCursorPaginatedUsers(cursor string, limit int) (users []User, hasNextPage bool, endCursor string) {
    // Start at the first user if no cursor is provided, otherwise find the starting point based on the cursor.
    startIndex := 0
    if cursor != "" {
        // If a cursor is provided, find the user with the given ID and set the starting index to the next user's index.
        for i, user := range allUsers {
            if user.ID == cursor {
                startIndex = i + 1  // Start from the next user after the cursor.
                break
            }
        }
    }

    // Calculate the ending index based on the limit, ensuring it doesn't exceed the length of the user list.
    endIndex := startIndex + limit
    if endIndex > len(allUsers) {
        endIndex = len(allUsers) // Adjust to the total number of available users if the limit exceeds the list length.
    }

    // Get the slice of users between the start and end indices.
    users = allUsers[startIndex:endIndex]

    // Check if there are more users to be fetched in the next page.
    hasNextPage = endIndex < len(allUsers)

    // If there are more users, set the endCursor to the ID of the last user in the current page.
    if hasNextPage {
        endCursor = users[len(users)-1].ID
    }

    // Return the list of users, whether there is a next page, and the cursor for the next page (if applicable).
    return users, hasNextPage, endCursor
}

ode Explanation:

  1. Input Parameters:

    • cursor: Identifier for the last fetched user.

    • limit: Number of items to retrieve.

  2. Determine startIndex:

    • Iterate through allUsers to locate the cursor.

    • Set the starting index to the item after the cursor.

  3. Calculate endIndex:

    • Add limit to startIndex.

    • Adjust endIndex if it exceeds the dataset size.

  4. Prepare the Response:

    • Slice the dataset using startIndex and endIndex.

    • Determine hasNextPage if more items exist.

    • Set endCursor to the ID of the last user in the current set.

  5. Flow:

    • For a cursor = "1" and limit = 2:

      • Start fetching after Alice.

      • Fetch 2 users: Bob and Charlie.

      • Set hasNextPage = true and endCursor = "3".


Scenario: Paginating User Data with Cursor-Based Pagination

Let’s go through a scenario where we apply cursor-based pagination to retrieve a list of users. We will paginate the data in multiple steps.

Scenario Flow:

  1. Page 1 Request (No cursor):

    • The client requests the first page with a limit of 2 users.

    • The backend fetches the first two users:

      • Users: Alice and Bob.
    • Since there are more users available, hasNextPage is set to true, and the endCursor is set to Bob’s ID (cursor = "2").

  2. Page 2 Request (Using cursor):

    • The client now uses the cursor from the previous response (cursor: "2") to request the next page, with a limit of 2 users.

    • The backend looks up Bob’s ID in the list, and fetches users starting from the next user (Charlie and Diana).

    • Users: Charlie and Diana.

    • Since more users are available, hasNextPage is set to true, and endCursor is set to Diana's ID (cursor = "4").

  3. Page 3 Request (Using cursor):

    • The client uses Diana’s ID (cursor: "4") as the cursor to fetch the next set of users.

    • The backend retrieves the remaining users, which is just Eve.

    • Users: Eve.

    • Since there are no more users left, hasNextPage is set to false, and endCursor is not needed.


Edge Cases in Cursor-Based Pagination

When implementing cursor-based pagination, it's important to account for potential edge cases. Below are some examples:

  1. Cursor Not Found:

    • If the cursor does not match any existing user ID, the function should either return an empty set or handle the error gracefully by starting from the beginning or sending a relevant message to the user.

    • Example: If the client sends a cursor: "999" but no user with that ID exists, the backend could return an empty list or an error message.

  2. Empty Dataset:

    • If there are no users in the dataset (or after applying the filter), the function should return an empty list and set hasNextPage to false.

    • Example: If the allUsers array is empty, the function should return users = [], hasNextPage = false, and no endCursor.

  3. No Next Page:

    • If the endIndex is at or beyond the total number of users, the function should set hasNextPage to false, indicating that no further pages are available.

    • Example: If there are 5 users, and the client requests 2 users starting at position 4, there will only be 1 user (Eve) left to return. After this, hasNextPage should be false.

  4. Limit Larger Than Remaining Data:

    • If the number of users left is less than the requested limit, the function should return the remaining users, and the hasNextPage flag should be false.

    • Example: If the total users are 6, and the client requests 5 users, but only 2 users are available after the cursor, the response should contain the remaining 2 users and hasNextPage = false.

  5. Consistent Data:

    • In a real-world scenario, data might change between requests (e.g., users can be added or removed). Cursor-based pagination ensures that the results remain consistent because each request starts from the last known item, which avoids skipping or duplicating users that may have been added or removed between requests.

Advantages and Disadvantages of Cursor-Based Pagination

Advantages:

  • Efficient for Large or Dynamic Datasets:
    Cursor-based pagination avoids the need to skip over large amounts of data, which is a common issue with offset-based pagination. This is especially useful for large and frequently updated datasets.

  • Consistency in Changing Datasets:
    The cursor always refers to the position of the last item in the previous page, which ensures consistency even if the data changes (e.g., users added or removed).

  • Better Performance:
    Cursor-based pagination improves performance since it doesn’t require recalculating offsets and can directly access the next set of results.

Disadvantages:

  • More Complex:
    Implementing cursor-based pagination requires a bit more setup and understanding than offset-based pagination, particularly when dealing with unique identifiers and pagination logic.

  • Limited Flexibility:
    Cursor-based pagination doesn’t allow jumping to a specific page like offset-based pagination does. The client must fetch pages sequentially, starting from the first.

To be continued (part-2) ……..

0
Subscribe to my newsletter

Read articles from Shivam Dubey directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shivam Dubey
Shivam Dubey