Table of Contents

Keyset pagination

GetPageAsync and GetPageProjectionAsync page through a collection by cursor, not by skip/limit. Cost is O(log N) per page regardless of how deep the page sits — there is no skip penalty when paging into the millions of documents and no spike when a user clicks "jump to last." Single-column sort with a _id tiebreaker; total count is intentionally not part of the result and should be fetched separately via CountAsync(predicate) (cache it client-side; it changes far less often than the page).

Basic usage

var first = await collection.GetPageAsync(
    pageSize: 25,
    position: PagePosition.First,
    sortBy: e => e.CreatedAt,
    ascending: false);

// "Next page" — feed the previous page's LastCursor back in
var next = await collection.GetPageAsync(
    pageSize: 25,
    position: PagePosition.After(first.LastCursor),
    sortBy: e => e.CreatedAt,
    ascending: false);

// "Previous page" — feed the current page's FirstCursor in via Before
var prev = await collection.GetPageAsync(
    pageSize: 25,
    position: PagePosition.Before(next.FirstCursor),
    sortBy: e => e.CreatedAt,
    ascending: false);

PagePosition has four factories:

Position Use
PagePosition.First First page in sort order
PagePosition.Last Final pageSize items in sort order (the trailing pageSize items, slid to align with the page boundary)
PagePosition.After(cursor, pageStep = 0) Page after the cursor; pageStep skips that many extra pages forward
PagePosition.Before(cursor, pageStep = 0) Page before the cursor; pageStep skips that many extra pages backward

CursorPage<T> exposes Items, FirstCursor, LastCursor, HasNext, HasPrevious. The cursors are opaque, URL-safe strings (Base64URL of a small BSON doc) — store them in query strings, hidden form fields, or component state.

Sort + filter

var page = await collection.GetPageAsync(
    pageSize: 50,
    position: PagePosition.After(cursor),
    predicate: e => e.Status == "active",
    sortBy: e => e.Name,
    ascending: true);

Predicates compose with the keyset filter — the page is the items matching predicate strictly past cursor in the sort order.

Index guidance

For each (sortBy, ascending) you page on, create the compound index { sortField: ±1, _id: ±1 }:

public override IEnumerable<CreateIndexModel<MyEntity>> Indices =>
[
    new(Builders<MyEntity>.IndexKeys.Ascending(e => e.CreatedAt).Ascending(e => e.Id),
        new CreateIndexOptions { Name = "createdAt_id" }),
];

Without the compound index the query still works but degrades to a sort + scan. With it, the planner uses an IXSCAN that walks straight to the cursor boundary and reads only the page-size's worth of documents.

Total count

var total = await collection.CountAsync(e => e.Status == "active");

CountAsync is a separate query because counts are typically far cheaper to cache than pages — once per filter-change, not once per page-flip. Don't bake it into the paging hot path.

CursorPager<TEntity, TKey> — easy path for grids

Most grid components (Radzen RadzenDataGrid, MudBlazor MudDataGrid, etc.) emit a (skip, pageSize) request on user navigation. CursorPager adapts that shape to the keyset API: it tracks the previous page's cursors, decodes the skip-delta into the appropriate PagePosition, falls back to skip-based GetManyAsync when the user does an arbitrary jump (e.g. clicking "page 17 of 200"), and re-issues cursors from the fallback's boundary documents so the next prev/next call resumes the keyset path.

private CursorPager<Order, ObjectId> _pager;

protected override void OnInitialized()
{
    _pager = new CursorPager<Order, ObjectId>(_orders);
}

private async Task LoadDataAsync(LoadDataArgs args)
{
    var (items, total) = await _pager.LoadAsync(
        skip: args.Skip ?? 0,
        pageSize: args.Top ?? 25,
        predicate: BuildPredicate(args.Filter),
        sortBy: o => o.CreatedAt,
        ascending: false);

    _orders = items;
    _totalCount = (int)total;
}

private void OnFilterChanged() => _pager.Reset();

CursorPager caches the total count per (predicate, sortBy, ascending) cache key — when any of those change it re-runs CountAsync and clears the cursors. Reset() clears all state (use it when the underlying data is known to have changed underfoot).

Manual path

When you need full control — e.g. cursor links shared between users, or persistence across sessions — work with GetPageAsync directly and stash FirstCursor/LastCursor wherever fits your application. CursorToken round-trips through ToString() and CursorToken.Parse(string) so it's safe to put in URLs, cookies, or hidden form fields. CursorToken.From(entity, sortBy, ascending) lets you mint a cursor pointing at any specific document — useful when restoring grid state from a deep link.

var anchorToken = CursorToken.From<Order, ObjectId>(anchor, o => o.CreatedAt, ascending: false);
var page = await collection.GetPageAsync(25, PagePosition.After(anchorToken),
    sortBy: o => o.CreatedAt, ascending: false);

Cursors are sort-bound: passing one issued for sortBy: x => x.Name to a call sorting by x => x.CreatedAt throws InvalidOperationException. This is intentional — silently re-sorting would return wrong results.

See also