Cursor-based pagination
Use cases for cursor-based pagination, performance benefits, and implementation strategies.
Cursor-based pagination is a method that uses a unique identifier (cursor) to paginate through data. It is more efficient than skip/limit pagination for large datasets because it avoids performance issues associated with large offsets.
Instead of skipping a number of items, cursor-based pagination uses a cursor to mark the last item on the current page. The next page is fetched starting from this cursor.
Use cases
Cursor-based pagination is ideal for:
- Handling very large datasets (10,000+ items)
- Implementing infinite scroll for extensive content feeds
- Content that changes frequently during user browsing
- Accessing data sequentially
- Ensuring performance at scale
The following query retrieves a page of articles using a cursor to mark the starting point. It returns the items, a new cursor for the next page, and the total count.
query GetPagesWithCursor($limit: Int = 10, $cursor: String) {
ArticlePage(
limit: $limit,
cursor: $cursor
orderBy: { StartPublish: DESC }
) {
items {
Name
RelativePath
StartPublish
}
cursor
total
}
}Initial request example to fetch the first page of results without a cursor.
{
"limit": 10
}Subsequent requests example to fetch the next page using the cursor returned from the previous query.
{
"limit": 10,
"cursor": "eyJTdGFydFB1Ymxpc2giOiIyMDI0LTAxLTE1VDEwOjMwOjAwWiIsIl9pZCI6ImFydGljbGUtMTIzIn0="
}Performance characteristics
Advantages
- Consistent performance regardless of dataset position.
- Memory efficiency for large datasets.
- Resilience to data changes during pagination.
- Optimal for sequential access patterns.
- Scalability to millions of items without performance degradation.
The following example shows that cursor-based pagination maintains consistent performance regardless of dataset size:
// Consistent performance regardless of dataset size
const firstPage = { limit: 20 }; // ~10ms
const page1000 = { cursor: "...", limit: 20 }; // ~10ms
const page10000 = { cursor: "...", limit: 20 }; // ~10msLimitations
- More complex implementation – Cannot jump to arbitrary pages.
- Sequential only – Must iterate through pages in order.
- Opaque navigation – Users cannot see the total pages or jump to a specific page.
- Cursor expiration – Cursors may become invalid over time.
Real-world implementation
The following query fetches a mixed content feed using cursor-based pagination, with support for multiple content types and type-specific fields through fragments:
query GetInfiniteContentFeed($limit: Int = 20, $cursor: String, $contentTypes: [String!]) {
Content(
where: { _typeName: { in: $contentTypes } }
limit: $limit,
cursor: $cursor
orderBy: { _modified: DESC }
) {
items {
_id
Name
_typeName
_modified
RelativePath
# Type-specific fields using fragments
... on ArticlePage {
TeaserText
}
... on ProductPage {
TeaserText
PageImage {
Url
}
}
}
cursor
total
}
}The following React component implements infinite scrolling using cursor-based pagination. It loads more content when the user scrolls near the bottom of the page.
function InfiniteContentFeed({ contentTypes }) {
const [items, setItems] = useState([]);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const { data } = await client.query({
query: GET_INFINITE_CONTENT_FEED,
variables: { cursor, limit: 20, contentTypes }
});
const newItems = data.Content.items;
setItems(prev => [...prev, ...newItems]);
setCursor(data.Content.cursor);
setHasMore(newItems.length === 20); // If less than limit, no more items
} catch (error) {
console.error('Failed to load more items:', error);
} finally {
setLoading(false);
}
};
// Intersection Observer for automatic loading
const { ref: loadMoreRef } = useIntersectionObserver({
threshold: 0.1,
onIntersect: loadMore
});
return (
<div>
<ContentList items={items} />
{hasMore && (
<div ref={loadMoreRef} className="load-more-trigger">
{loading && <LoadingSpinner />}
</div>
)}
{!hasMore && <div>No more content to load</div>}
</div>
);
}Performance optimization techniques
Stable sort order
The following query ensures consistent ordering by using a secondary sort field _id to avoid duplicate or missing items when multiple entries share the same primary sort value.
query StableCursorPagination($cursor: String) {
ArticlePage(
cursor: $cursor
# Ensure stable ordering with unique field as secondary sort
orderBy: { StartPublish: DESC, _id: ASC } # Ensures consistent ordering for items with same StartPublish
) {
items { Name }
cursor
}
}Appropriate page sizes
The following snippet defines optimal page sizes for different use cases to balance performance and user experience:
// Good page sizes for different use cases
const PAGE_SIZES = {
mobile: 10, // Smaller screens
desktop: 20, // Standard desktop
feeds: 30, // Social feeds
admin: 50 // Admin interfaces
};Advanced pagination patterns
Enhance the user experience with these advanced pagination strategies for large datasets.
- Hybrid approach for large datasets
- Search results with smart pagination
- Adaptive pagination based on result count
Hybrid approach for large datasets
Combine pagination methods to optimize the user experience. Use infinite scrolling for large datasets and traditional pagination for smaller ones.
The following component switches between infinite scroll and traditional pagination based on the dataset size:
function HybridPagination({ totalItems }) {
const [viewMode, setViewMode] = useState('pages'); // 'pages' or 'infinite'
const isLargeDataset = totalItems > 10000;
if (isLargeDataset && viewMode === 'infinite') {
return <CursorBasedInfiniteScroll />;
} else {
return <SkipLimitPagination maxPages={500} />; // Limit deep pagination
}
}Search results with smart pagination
Implement smart pagination for search results, adapting the method based on the result count.
query SearchWithSmartPagination($searchTerm: String!, $skip: Int = 0, $limit: Int = 10) {
Content(
where: {
_fulltext: { match: $searchTerm }
}
skip: $skip
limit: $limit
orderBy: { _ranking: SEMANTIC }
) {
items {
Name
_score
_typeName
RelativePath
}
total
}
}Adaptive pagination based on result count
The following component switches to infinite scroll for large result sets and uses traditional pagination for smaller ones:
function SearchResultsPagination({ searchTerm }) {
const [page, setPage] = useState(1);
const pageSize = 10;
const { data } = useQuery(SEARCH_QUERY, {
variables: { searchTerm, skip: (page - 1) * pageSize, limit: pageSize }
});
const totalResults = data?.Content?.total || 0;
// Switch to infinite scroll for large result sets
if (totalResults > 1000) {
return <InfiniteScrollResults searchTerm={searchTerm} />;
}
return <TraditionalPagination data={data} page={page} setPage={setPage} />;
}Error handling and edge cases
The following function handles cursor expiration by restarting pagination from the beginning if the cursor is invalid:
async function safeCursorQuery(cursor) {
try {
const { data } = await client.query({
query: CURSOR_QUERY,
variables: { cursor, limit: 20 }
});
return data;
} catch (error) {
if (error.message.includes('invalid cursor')) {
// Cursor expired, restart from beginning
console.warn('Cursor expired, restarting pagination');
return await safeCursorQuery(null);
}
throw error;
}
}Constraints for cursor-based pagination
When using cursor-based pagination, remember the following constraints:
- Do not combine cursor with skip.
- Use limit > 0;
limit: 0is not supported with cursor. - Do not use cursor with
_linkor_childrenfields.
Updated 16 days ago
