Pagination Patterns: Cursor vs Offset in .NET APIs
Every API that returns collections eventually needs pagination. The two dominant patterns are offset-based (page number + page size) and cursor-based (a pointer to the last item seen). Each has trade-offs in performance, usability, and consistency. This article implements both in ASP.NET Core and helps you choose.
Offset-Based Pagination
Offset pagination is the simpler model. The client sends page and pageSize parameters, and the server uses SKIP and TAKE:
app.MapGet("/api/products", async (
int page,
int pageSize,
AppDbContext db,
CancellationToken ct) =>
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var totalCount = await db.Products.CountAsync(ct);
var items = await db.Products
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return Results.Ok(new PagedResponse<Product>
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling((double)totalCount / pageSize)
});
});
The response:
{
"items": [ ... ],
"page": 2,
"pageSize": 20,
"totalCount": 157,
"totalPages": 8
}
The Envelope
public class PagedResponse<T>
{
public required List<T> Items { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
}
Offset Pagination Problems
Offset pagination has two well-known issues:
- Performance degrades with large offsets.
SKIP 100000forces the database to scan and discard 100,000 rows. On large tables, deep pages become slow. - Inconsistent results when data changes. If a row is inserted or deleted between page requests, items shift — causing duplicates or missed rows.
For small, relatively stable datasets (admin dashboards, settings lists), these issues rarely matter. For large, actively-changing datasets (activity feeds, search results), cursor pagination is more robust.
Cursor-Based Pagination
Cursor pagination uses a value from the last item on the current page to fetch the next page. The "cursor" is typically an encoded representation of the sort key.
app.MapGet("/api/products", async (
string? cursor,
int pageSize,
AppDbContext db,
CancellationToken ct) =>
{
pageSize = Math.Clamp(pageSize, 1, 100);
var query = db.Products.OrderBy(p => p.Id).AsQueryable();
if (cursor is not null)
{
var lastId = DecodeCursor(cursor);
query = query.Where(p => p.Id > lastId);
}
var items = await query
.Take(pageSize + 1) // Fetch one extra to determine if there's a next page
.ToListAsync(ct);
var hasNextPage = items.Count > pageSize;
if (hasNextPage)
items.RemoveAt(items.Count - 1);
return Results.Ok(new CursorPagedResponse<Product>
{
Items = items,
NextCursor = hasNextPage ? EncodeCursor(items[^1].Id) : null,
HasNextPage = hasNextPage
});
});
The cursor encoding:
static string EncodeCursor(int id) =>
Convert.ToBase64String(BitConverter.GetBytes(id));
static int DecodeCursor(string cursor) =>
BitConverter.ToInt32(Convert.FromBase64String(cursor));
The response:
{
"items": [ ... ],
"nextCursor": "FAAAAA==",
"hasNextPage": true
}
The Envelope
public class CursorPagedResponse<T>
{
public required List<T> Items { get; set; }
public string? NextCursor { get; set; }
public bool HasNextPage { get; set; }
}
Why Cursors Perform Better
The query WHERE Id > @lastId ORDER BY Id TAKE 20 uses an index seek. It does not matter whether you are on page 1 or page 10,000 — the cost is the same. No rows are scanned and discarded.
Compound Cursors
When sorting by non-unique columns, the cursor must include a tiebreaker:
app.MapGet("/api/products", async (
string? cursor,
int pageSize,
AppDbContext db,
CancellationToken ct) =>
{
pageSize = Math.Clamp(pageSize, 1, 100);
var query = db.Products
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.AsQueryable();
if (cursor is not null)
{
var (name, id) = DecodeCompoundCursor(cursor);
query = query.Where(p =>
p.Name.CompareTo(name) > 0 ||
(p.Name == name && p.Id > id));
}
var items = await query.Take(pageSize + 1).ToListAsync(ct);
var hasNextPage = items.Count > pageSize;
if (hasNextPage) items.RemoveAt(items.Count - 1);
return Results.Ok(new CursorPagedResponse<Product>
{
Items = items,
NextCursor = hasNextPage
? EncodeCompoundCursor(items[^1].Name, items[^1].Id)
: null,
HasNextPage = hasNextPage
});
});
Which to Choose
| Factor | Offset | Cursor |
|---|---|---|
| Simplicity | Simpler to implement and understand | Requires cursor encoding/decoding |
| Random page access | Yes — jump to page 5 | No — must traverse sequentially |
| Performance at scale | Degrades with deep pages | Constant performance |
| Data consistency | Items can shift between pages | Stable — no duplicates or gaps |
| Total count | Easy to include | Requires a separate count query |
Use offset pagination for admin UIs, internal tools, and small datasets where random page access matters. Use cursor pagination for public APIs, feeds, search results, and any dataset where performance at scale or consistency matters.