What is GraphQL Pagination and Why Does It Matter?

Advantages and disadvantages of offset and cursor pagination

Agility CMS
Agility CMS
What is GraphQL Pagination and Why Does It Matter?

GraphQL pagination is the practice of breaking down large query results into smaller, more manageable chunks rather than fetching everything in one go. This is analogous to reading a book page by page instead of grabbing the entire book at once. 

Handling data in smaller pieces prevents overwhelming both the client and the server. Fetching a huge list of records in a single GraphQL query can be memory-intensive and slow, demanding more backend processing power and bandwidth.

By paginating results, applications become more efficient and faster-loading, and the user experience improves because users aren’t flooded with an enormous list of data all at once. 

In GraphQL (as in REST APIs), pagination is essential for performance and usability when dealing with large datasets. Instead of requesting, say, 10,000 items in one query, a client can ask for the first 10 or 20, then the next, and so on.

This not only speeds up responses but also ensures your app or browser doesn’t crash under the weight of too much data . Pagination also helps maintain clarity in user interfaces – for example, by showing data page by page or via “load more” buttons, users can navigate through results without confusion. 

Types of GraphQL Pagination

There are two common approaches to implement pagination in GraphQL: offset-based pagination and cursor-based pagination.

Both serve the same purpose of slicing data into pages, but they differ in how they reference the position in the dataset and how they handle changing data. In the sections below, we’ll explore each method, with simple explanations, code examples, and a breakdown of pros, cons, and best use cases for GraphQL pagination.

How Offset Pagination Works (GraphQL Example)

Using offset pagination in GraphQL typically involves adding arguments to your query. For example, imagine we have a GraphQL field posts that returns a list of blog posts. We can define it to accept an offset and a limit. A query to get the first 5 posts after skipping the first 10 might look like this:

   query GetPosts {

     posts(offset: 10, limit: 5) {

       id

       title

       content

     }

   }

In this example, offset: 10 means “skip the first 10 items” (so start from the 11th item), and limit: 5 means “return at most 5 items”. The result would include posts 11 through 15 (assuming the list is zero-indexed or that offset 10 corresponds to the 11th item).

If you wanted the next page (posts 16–20), you would increase the offset to 15 (skip 15 items) while keeping the limit at 5.

Not all GraphQL APIs use the exact names offset and limit – some might use skip instead of offset, for instance the Contentful GraphQL API uses a skip parameter serving the same purpose  Always check the API documentation or schema to know the correct argument names.

Illustrated example of offset pagination

Advantages of Offset Pagination

  • Simplicity: Offset pagination is easy to implement and understand. You just use a numeric index to indicate the starting point. Most databases natively support offset/limit (e.g., SQL’s LIMIT … OFFSET clause), and many GraphQL servers expose these as arguments by default. Because of this simplicity, offset pagination is widely adopted and often the default approach for paginating data. 

  • Arbitrary Page Access: It allows jumping to any page of results easily. If your UI needs direct access to “page 5” of results, you can calculate the offset (e.g., for page 5 with 10 items per page, offset = 40) and fetch directly. This random access is useful for traditional page-numbered navigation where users might skip ahead or jump around in the result set. 

  • Works with Static Ordering: If the data order is fixed (e.g., sorted by title alphabetically or by date and not expecting new entries in between), offset pagination will consistently give the expected results for each page. It’s a fine fit for datasets that don’t change frequently during navigation.

Disadvantages of Offset Pagination

  • Performance on Large Datasets: Offset pagination can become slow and inefficient for large data sets. The server (and database) may still have to scan through or skip a lot of records to reach the offset starting point . For example, fetching page 1000 means the database might skip 999 pages worth of records first, which is I/O-intensive and can tax the database. The more records you skip, the longer the query might take, potentially leading to timeouts on very high offsets.

  • Inconsistency with Dynamic Data: If new records are inserted or removed while a user is paginating, offsets can lead to missing or duplicate items on subsequent pages. This is because the offset is based on a fixed count, not a specific item. If an item gets added to an earlier page, all subsequent pages shift by one. The GraphQL documentation warns that if new records are added after the user’s initial request, “offset calculations for subsequent pages may become ambiguous” . In practice, this could mean users see the same item twice or skip some items entirely when data changes between requests.

  • Not Suited for Real-Time Feeds: For data that changes very frequently (like social media feeds or live data streams), offset pagination is a poor choice . The order or number of items can change in the time between page requests. For example, in a Twitter feed sorted by newest first, using offset pagination could result in confusing jumps or missing tweets if new ones come in – essentially because the “pages” keep shifting.

  • No Built-in Next/Previous State: The offset approach doesn’t inherently tell you if there are more pages beyond the current one (you often need an extra query or to know the total count of items to decide if you’ve reached the end). Clients often need a separate query to get the total count of items to know how many pages exist, which can add complexity.

When to Use Offset Pagination

Offset-based pagination works best for static or slowly changing datasets and relatively moderate data sizes . If your data is stored in a CMS or database where entries aren’t frequently inserted in the middle of the list, and the total number of records is not extremely large, offset pagination is an acceptable and simple solution. Typical scenarios include:

  • Static content lists: e.g. a list of blog posts, product catalogs, or articles that don’t get new entries every second. In these cases, the data order is fairly stable (new posts might only append to the end, especially if sorted by date).

  • Small to medium datasets: on the order of a few hundred or a few thousand items. The performance issues of offset are less noticeable at smaller scales.

  • Paginated UI with direct page access: If you need numbered page navigation (page 1, 2, 3, …) in your UI, offset is straightforward to implement. You can calculate offsets from page numbers easily.

  • Simplicity over absolute consistency: When you prefer a simpler implementation and can tolerate the slight risk of an item or two being missed or duplicated if the data updates. For internal tools or reports where data is mostly static, this trade-off is often fine .

In summary, use offset pagination for GraphQL queries when data changes slowly and your primary goal is ease of implementation. For instance, querying a content repository for articles might be perfectly fine with offset pagination, especially if the API or database already supports skip/limit mechanics.

Cursor-Based Pagination in GraphQL

Cursor-based pagination is a more advanced technique that uses a cursor (a unique identifier for a specific item in the list) to keep track of the current position. Instead of saying “give me items 11–15”, a cursor-based query says “give me 5 items after this specific item (cursor).”

The cursor is often an encoded value (e.g., a database ID or composite key, sometimes base64 encoded to appear opaque) that represents a point in the data. GraphQL cursor pagination is known for being more efficient and reliable for large or changing datasets.

This method shines when you need to ensure that pagination isn’t thrown off by new or deleted records, and it’s the recommended approach in the official GraphQL docs for most cases. 

How Cursor Pagination Works (GraphQL Example)

In GraphQL, cursor-based pagination is commonly implemented using a pattern often referred to as the “Relay Cursor Connections” specification (inspired by Facebook’s Relay library). The idea is that the server returns not just a list of items, but also cursor metadata that the client can use to fetch the next or previous set of items.

A typical GraphQL schema for cursor pagination might look like this:

   type Query {

     users(first: Int, after: String, last: Int, before: String): UserConnection

   }


   type UserConnection {

     edges: [UserEdge]

     pageInfo: PageInfo

     totalCount: Int

   }


   type UserEdge {

     node: User       # the actual User item

     cursor: String   # a cursor pointing to this item’s position

   }


   type PageInfo {

     hasNextPage: Boolean

     hasPreviousPage: Boolean

     endCursor: String

     startCursor: String

   }

In the above schema, querying users(first: 5) would give you a UserConnection object containing the first 5 users (inside edges) and some helpful info like endCursor (cursor of the last user in that page) and hasNextPage. To get the next page, you use the endCursor value in an after argument for the next query. For example:

   query {

     users(first: 5) {

       edges {

         node { id name email }

         cursor

       }

       pageInfo {

         hasNextPage

         endCursor

       }

     }

   }

This might return a response with edges containing users A, B, C, D, E and an endCursor of say "cursor123". To get the next 5 users, you’d query:

   query {

     users(first: 5, after: "cursor123") {

       edges { ... }

       pageInfo { hasNextPage endCursor }

     }

   }

The server will interpret after: "cursor123" as “start after the item represented by cursor123, and return the next 5 items.” This way, the client doesn’t need to know numeric positions – it just uses the cursor provided by the previous result to get the subsequent result set. The cursor itself is usually an opaque string. It could encode an internal ID or a timestamp or any ordering key, but the client treats it as a bookmark token only.

Cursor pagination ABC

Cursor pagination DEF

Some GraphQL APIs also support backwards pagination with last and before parameters in a similar way, allowing you to page in reverse (useful for navigating to older items in a feed, for example).

In such cases, the query might supply last: 5, before: "<someCursor>" to get 5 items before a certain cursor (going in the reverse direction). The pageInfo typically provides both hasNextPage and hasPreviousPage, along with both an endCursor and startCursor to facilitate this bi-directional navigation. 

Advantages of Cursor Pagination

  • Robust with Dynamic Data: Cursor-based pagination is ideal for fast-changing or real-time data. Even if new items are inserted or removed from the dataset, the cursor approach ensures you won’t skip or duplicate items inadvertently. For example, if new records are added to the beginning of a list while you’re paginating through it, cursor pagination will still return the correct next items without confusion. As Apollo’s documentation notes, cursor pagination “eliminates the possibility of skipping items and displaying the same item more than once” when compared to offset pages.

  • Performance at Scale: For very large datasets (thousands or millions of records), cursor pagination tends to be more efficient and faster. The database query can use indexed keys (like an auto-increment ID or timestamp) to fetch the next batch of results without scanning from the start. In fact, one analysis found significant performance gains (e.g., avoiding deep offsets can dramatically speed up queries) . In practice, this means cursor pagination can handle high offsets gracefully; you just start from a given position and get the next chunk.

  • Stable Ordering: Cursors inherently tie to a specific item’s position, so the results remain stable as you paginate. If item C has cursor “cursor123”, it doesn’t matter if the total count changes – the next page will start after C. This makes cursor pagination the preferred method when consistency is important. It is often used for feeds sorted by time or other continuous values, because it guarantees a continuous scroll without overlap or gaps.

  • GraphQL Relay Compatibility: The Relay cursor connection model (using edges and pageInfo) is a well-established GraphQL best practice. Many GraphQL APIs (e.g. GitHub’s API, Shopify, Twitter’s GraphQL, etc.) implement this pattern . By using cursor pagination, you’re aligning with a common standard, which can make client-side code and libraries (like Apollo Client or Relay) easier to use. The Relay model also allows including useful metadata (like totalCount of items, if provided, and any additional edge-specific info) along with your paginated data.

Disadvantages of Cursor Pagination

  • Implementation Complexity: Setting up cursor-based pagination is more complex than offset pagination. The server needs to generate and return cursor values, and the client needs to manage those cursors. If you implement it from scratch, you have to ensure cursors are opaque and encodings remain consistent (often base64 encoding an ID + maybe some sorting info). There’s a bit of extra bookkeeping compared to the simplicity of offsets. However, many frameworks and libraries provide support or patterns to handle this.

  • Sequential Access: Unlike offset, you typically cannot jump directly to an arbitrary page with cursors (there’s no concept of “page 5” without traversing pages 1–4, unless you store specific cursors from before). This can be a limitation for UIs that want direct page navigation. For example, you can implement "next" and "previous" buttons easily with cursors, but a "go to page 10" might require iteratively using the after cursors or a different approach (like providing a separate indexed search).
  • Dependence on Sorting: Cursor pagination requires a reliable sorting order that doesn’t change between queries. Typically, you use an immutable property like a unique ID or a creation timestamp as part of the cursor. If the sorting of items changes (for example, if you sort by a field that can be updated), the cursor approach can become tricky. Most implementations avoid this by using a fixed sort order (like chronological or insertion order).

  • No Native Total Count: Often, cursor-based APIs don’t return the total count of items in a straightforward way (though some do via a totalCount field as in our example schema). This means you might not easily know how many pages of data exist. For many use cases (infinite scroll), this isn’t a concern, but if you need to display “Page X of Y” to users, you might need an extra field or separate query to get the total count.

  • Backwards Pagination State: While forward pagination is commonly implemented, not all APIs implement the before/last (backwards) pagination. If users need to jump back and forth a lot, you have to carefully manage the stored cursors or use the provided startCursor for going in reverse. This is more of a consideration than a hard drawback, since many implementations do support it , but it adds complexity to client-side state management.

When to Use Cursor Pagination

Cursor-based pagination is generally the best choice for large or rapidly changing data. Consider using cursor pagination in scenarios such as:

  • Real-time or frequently updated data: For example, social media feeds, news feeds, or logs. As new data comes in or out, cursor pagination ensures the paging sequence remains correct and users don’t miss information. If your GraphQL query is pulling in data that gets updated often (likes, comments, new items, etc.), use cursors.

  • Very large datasets: When dealing with thousands or millions of records, the efficiency of cursor pagination is crucial . Applications that page through large tables (like a big user list or transaction history) will benefit from the performance of cursors.

  • Infinite scroll or “Load more” interfaces: If your application uses infinite scrolling, cursor pagination is practically a must . It allows you to continuously fetch additional data without recalculating offsets. Users can keep scrolling smoothly, and you just use the last cursor until no more data.

  • When data consistency is important: If showing duplicate or missing entries due to concurrent data changes would be a serious problem (for example, financial data or analytics dashboards), then cursor-based approach is preferred for its stability.

  • Aligning with third-party APIs or standards: If you’re consuming or building an API that follows the Relay spec or similar, you’ll use cursor pagination. Many modern GraphQL APIs provide only cursor-based pagination. For instance, GitHub’s GraphQL API exclusively uses cursors for paging through repository issues, commits, etc., so developers are often expected to handle that style.

In short, choose cursor pagination for GraphQL queries whenever you need a robust, scalable pagination solution that can handle changes in data gracefully. It offers a bit more complexity but pays off with reliability in data-intensive applications.

Cursor vs Offset Pagination: Comparison Table

To summarize the differences between offset-based and cursor-based pagination in GraphQL, the following table highlights the key points of comparison:

Aspect Offset Pagination Cursor Pagination
Implementation Simplicity Very simple – uses numeric index (offset) and limit. Easy to understand and implement without special schema types. More complex – requires managing cursors (often opaque strings) and a connection model (edges, pageInfo). Typically uses a specific schema pattern for nodes/edges.
Performance on Large Data Can degrade for large datasets; high offsets cause the database to skip many records, leading to slower queries. Might even timeout on extremely large offsets. Designed for efficiency at scale; uses indexed lookups via cursors so performance remains good even deep into the dataset. Able to fetch the next page without scanning all earlier items.
Handling New/Changing Data Prone to issues if data changes between requests. New or removed items can shift results, causing duplicates or omissions in pagination sequence. Best for static or infrequently changing data.  Resilient to data changes. Cursor marks a position relative to a specific item, so new data doesn’t disrupt paging order. Prevents skipping or repeating items, ideal for rapidly changing datasets. 
Navigating Pages Easy random access: can jump to any page by calculating the offset (e.g., page * size). Good for page number UI.  Sequential access: typically only next or previous page via cursor. Hard to jump arbitrarily without iterating or storing intermediate cursors. Suited for “load more” or infinite scroll UI rather than direct page numbers. 
Client Complexity Client just needs to manage page number or offset value. Simple state (e.g., current page index). Client must manage cursor tokens. Usually store the last cursor to get next page, and possibly the first cursor for previous page. More state to handle (e.g., pageInfo flags).
Total Count Availability Often easy to get total count (some APIs return it or it can be a separate query), enabling display of total pages. Total count may not be readily available unless explicitly provided (totalCount). Cursor approach focuses on “what’s next” rather than “how many in total”.
Use Case Fit Best for static, small-to-midsize data sets where simplicity and direct page access matter (e.g., static content lists, admin dashboards with stable data). Best for dynamic, large data sets where consistency and performance matter (e.g., live feeds, big databases, infinite scroll interfaces). Often adopted as a best practice in modern APIs. 

Best Practices and Recommendations

  • Choose the Right Method for Your Data: If your data is highly dynamic or very large, favor cursor-based pagination, as it will handle updates and scale better . If your data is relatively static and small, offset pagination is simpler and will suffice . In fact, many projects implement both methods depending on the dataset – use cursor pagination for things like activity feeds, but maybe offset for a static list of reference data, but that’s an edge case. Generally, design your GraphQL API pagination to make the frontend implementation as simple as possible for the given UX.

  • Limit Results Per Page: Don’t let clients request arbitrarily huge pages. It’s wise to set an upper bound (for example, max 100 items per page, or even 20) to prevent someone from requesting thousands of records in one go . This protects your backend from heavy load and ensures quick responses. GraphQL servers can enforce this in the schema or resolvers.

  • Use Stable Sorting for Cursors: If you implement cursor pagination, design your cursor around a stable, unique field (or combination of fields). A common approach is to use a timestamp or an auto-incrementing ID as the cursor value. Even better, opaque cursors (like a Base64-encoded string containing the ID or offset) are recommended so that clients don’t make assumptions about their content. This gives you flexibility to change the underlying cursor format without breaking clients. 
  • Provide pageInfo and Total Count (if needed): Following the Relay-style schema with a PageInfo object (including hasNextPage, hasPreviousPage, etc.) is very useful for clients to know when to stop fetching. If knowing the total number of items is important (for example, showing “50 of 500 items loaded”), consider including a totalCount field in your GraphQL connection type. This isn’t always provided by default due to performance cost, but it can be included if necessary.

  • Prevent Over-Querying: Be mindful of how pagination interacts with other query parts. For instance, if each item needs additional sub-queries (the infamous N+1 problem), try to batch those or use dataloader techniques . This isn’t specific to pagination, but pagination means clients might fetch in smaller chunks, which can highlight inefficiencies in how related data is fetched. Batch your resolvers or use caching where appropriate.

  • Offer Forward and Backward Navigation (for Cursor Paging): If using cursor pagination, consider implementing both after (for next page) and before (for previous page) parameters in your schema . This gives consumers flexibility to page in both directions, which can be helpful for certain UIs (e.g., a chat where you might scroll up to load previous messages). Along with this, ensure your client stores the necessary cursors (like the first cursor seen for jumping back).

  • Think in Terms of UX: Align your pagination strategy with the user experience. For example, if you have a classic paginated list with page numbers, offset might be more straightforward to implement for that UI. If you have an infinite scroll or a “load more” button, cursor is more naturally suited. In some cases, a hybrid approach could work – e.g., use offset for first 100 pages then require filters or cursor for deeper data, but that’s an edge case. Generally, design your GraphQL API pagination to make the frontend implementation as simple as possible for the given UX.

By following these practices, you ensure that your GraphQL API remains efficient and your clients (whether they are web applications or mobile apps) have a smooth experience retrieving data.

Conclusion

In summary, GraphQL pagination is crucial for managing large sets of data without degrading performance or usability. Offset pagination offers simplicity and works well for static or small datasets, but it struggles with large or frequently changing data. Cursor-based pagination is more complex but provides better stability and efficiency for big, dynamic datasets. Understanding the differences — cursor vs offset — allows you to make an informed decision and implement the approach that best fits your project's needs.

Now that you have a solid grasp of pagination in GraphQL with examples of each approach, consider applying these concepts in your own projects. If you’re looking to build a project with a ready-made content backend, explore Agility CMS or similar headless CMS platforms that support GraphQL.

Agility CMS, for instance, provides a GraphQL content API which you can use to query content with pagination. It’s a great way to see these concepts in action with real-world data. Explore Agility CMS and take your GraphQL skills further by building fast, efficient applications with a robust content infrastructure. Happy querying!

Agility CMS
About the Author
Agility CMS

Agility CMS is Canada's original headless CMS platform. Since 2002, Agility has helped companies across Canada and around the world better manage their content. Marketers are free to create the content they want, when they want it. Developers are empowered to build what they want, how they want.

Take the next steps

We're ready when you are. Get started today, and choose the best learning path for you with Agility CMS.