Disclaimer: This website requires Please enable JavaScript in your browser settings for the best experience.

Dev guideRecipesAPI ReferenceChangelog
Dev guideRecipesUser GuidesNuGetDev CommunityOptimizely AcademySubmit a ticketLog In
Dev guide

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 };     // ~10ms

Limitations

  • 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

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: 0 is not supported with cursor.
  • Do not use cursor with _link or _children fields.