Pagination Strategies in .NET — Offset vs Keyset
Implement API pagination in .NET: offset-based pagination for simple cases, keyset (cursor) pagination for large tables, EF Core and Dapper implementations, and returning pagination metadata.
Pagination Strategies in .NET — Offset vs Keyset
Every API that returns lists needs pagination. The choice between offset and keyset pagination determines whether your API stays fast as the table grows.
Why Pagination Matters
Without pagination:
GET /orders → returns 2 million rows → OOM, timeout, or DoS
With pagination:
GET /orders?page=1&pageSize=20 → 20 rows, fast
GET /orders?cursor=abc123 → 20 rows, fast at any depthStrategy 1: Offset Pagination
Page by row number. Simple to implement, familiar to clients.
// Query and result types
public record GetOrdersQuery(int Page = 1, int PageSize = 20) : IRequest<PagedResult<OrderDto>>;
public record PagedResult<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount,
int TotalPages,
bool HasNextPage,
bool HasPreviousPage);// EF Core implementation
public class GetOrdersHandler(AppDbContext context)
: IRequestHandler<GetOrdersQuery, PagedResult<OrderDto>>
{
public async Task<PagedResult<OrderDto>> Handle(GetOrdersQuery q, CancellationToken ct)
{
var query = context.Orders
.Where(o => o.Status != "Deleted")
.OrderByDescending(o => o.CreatedAt);
// Count before pagination — required for TotalPages
var totalCount = await query.CountAsync(ct);
var items = await query
.Skip((q.Page - 1) * q.PageSize)
.Take(q.PageSize)
.Select(o => new OrderDto(o.Id, o.CustomerId, o.Total, o.Status))
.AsNoTracking()
.ToListAsync(ct);
return new PagedResult<OrderDto>(
Items: items,
Page: q.Page,
PageSize: q.PageSize,
TotalCount: totalCount,
TotalPages: (int)Math.Ceiling(totalCount / (double)q.PageSize),
HasNextPage: q.Page * q.PageSize < totalCount,
HasPreviousPage: q.Page > 1);
}
}// API endpoint
[HttpGet("orders")]
[ProducesResponseType<PagedResult<OrderDto>>(200)]
public async Task<IActionResult> GetOrders(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100); // cap to prevent abuse
var result = await mediator.Send(new GetOrdersQuery(page, pageSize), ct);
return Ok(result);
}// Response
{
"items": [...],
"page": 2,
"pageSize": 20,
"totalCount": 450,
"totalPages": 23,
"hasNextPage": true,
"hasPreviousPage": true
}Offset pagination problems:
- SKIP 10000 TAKE 20: database still scans 10,020 rows — O(n) cost
- Page drift: a new item inserted at page 1 shifts all rows — page 2 shows a duplicate
- COUNT(*) is expensive on large tables
- Not suitable for > 100,000 rowsStrategy 2: Keyset (Cursor) Pagination
Page by the last seen row's values. Fast at any depth.
// Cursor encodes the last seen row's sort key values
public record CursorPageQuery(
string? Cursor = null, // base64-encoded cursor from previous page
int PageSize = 20)
: IRequest<CursorPageResult<OrderDto>>;
public record CursorPageResult<T>(
IReadOnlyList<T> Items,
string? NextCursor, // null if last page
bool HasNextPage);// Cursor = the sort key of the last item on the previous page
// Encoded as base64 JSON to keep the API opaque
public record OrderCursor(DateTime CreatedAt, int Id);
public class GetOrdersCursorHandler(AppDbContext context)
: IRequestHandler<CursorPageQuery, CursorPageResult<OrderDto>>
{
public async Task<CursorPageResult<OrderDto>> Handle(CursorPageQuery q, CancellationToken ct)
{
var cursor = DecodeCursor(q.Cursor);
var query = context.Orders.AsNoTracking();
if (cursor is not null)
{
// Items after the cursor — keyset filter
// (CreatedAt, Id) < (cursor.CreatedAt, cursor.Id) for descending order
query = query.Where(o =>
o.CreatedAt < cursor.CreatedAt ||
(o.CreatedAt == cursor.CreatedAt && o.Id < cursor.Id));
}
// Fetch one extra to detect if there is a next page
var items = await query
.OrderByDescending(o => o.CreatedAt)
.ThenByDescending(o => o.Id)
.Take(q.PageSize + 1)
.Select(o => new { o.Id, o.CustomerId, o.Total, o.Status, o.CreatedAt })
.ToListAsync(ct);
var hasNextPage = items.Count > q.PageSize;
if (hasNextPage)
items.RemoveAt(items.Count - 1); // remove the extra sentinel item
var nextCursor = hasNextPage
? EncodeCursor(new OrderCursor(items.Last().CreatedAt, items.Last().Id))
: null;
return new CursorPageResult<OrderDto>(
Items: items.Select(o => new OrderDto(o.Id, o.CustomerId, o.Total, o.Status)).ToList(),
NextCursor: nextCursor,
HasNextPage: hasNextPage);
}
private static OrderCursor? DecodeCursor(string? cursor)
{
if (cursor is null) return null;
var json = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
return JsonSerializer.Deserialize<OrderCursor>(json);
}
private static string EncodeCursor(OrderCursor cursor)
{
var json = JsonSerializer.Serialize(cursor);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
}// API endpoint
[HttpGet("orders/cursor")]
public async Task<IActionResult> GetOrdersCursor(
[FromQuery] string? cursor = null,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var result = await mediator.Send(new CursorPageQuery(cursor, pageSize), ct);
return Ok(result);
}// Response
{
"items": [...],
"nextCursor": "eyJDcmVhdGVkQXQiOiIyMDI2LTA1LTIxVDEwOjMwOjAwWiIsIklkIjo0MjF9",
"hasNextPage": true
}
// Next request:
// GET /orders/cursor?cursor=eyJDcmVhdGVkQXQiOiIyMDI2...Keyset pagination advantages:
- O(1) regardless of depth — uses index seek, not scan
- No page drift — new items don't affect cursor position
- No COUNT(*) needed
Keyset pagination limitations:
- No random access (cannot jump to page 5)
- Cursor is tied to the sort order — changing sort requires new cursor
- More complex implementationDapper Implementation
// Offset with Dapper
public async Task<PagedResult<OrderDto>> GetPagedAsync(int page, int pageSize, CancellationToken ct)
{
using var conn = dbFactory.CreateReadConnection();
const string dataSql = """
SELECT Id, CustomerId, Total, Status
FROM Orders
WHERE Status != 'Deleted'
ORDER BY CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
const string countSql = "SELECT COUNT(*) FROM Orders WHERE Status != 'Deleted'";
var items = (await conn.QueryAsync<OrderDto>(dataSql, new { Offset = (page-1)*pageSize, PageSize = pageSize })).ToList();
var totalCount = await conn.ExecuteScalarAsync<int>(countSql);
return new PagedResult<OrderDto>(items, page, pageSize, totalCount,
(int)Math.Ceiling(totalCount / (double)pageSize),
page * pageSize < totalCount, page > 1);
}
// Keyset with Dapper
public async Task<List<OrderDto>> GetAfterCursorAsync(DateTime? afterDate, int? afterId, int pageSize)
{
using var conn = dbFactory.CreateReadConnection();
const string sql = """
SELECT Id, CustomerId, Total, Status
FROM Orders
WHERE (@AfterDate IS NULL OR CreatedAt < @AfterDate
OR (CreatedAt = @AfterDate AND Id < @AfterId))
ORDER BY CreatedAt DESC, Id DESC
LIMIT @PageSize
""";
return (await conn.QueryAsync<OrderDto>(sql, new { AfterDate = afterDate, AfterId = afterId, PageSize = pageSize })).ToList();
}Comparison Summary
Offset Keyset/Cursor
Implementation Simple Moderate
Random access Yes No
Performance O(n) for deep O(1) always
Page drift Yes No
Total count Yes No
Sort flexibility Any column Must match cursor
Best for Small tables Large tables, infinite scroll
Admin pages Real-time feeds, APIsInterview Answer
"Offset pagination uses SKIP N TAKE M — simple but O(n) because the database must scan all skipped rows; on a 10-million row table, page 500 scans 10,000 rows just to skip them. Page drift is also a problem: inserting a row at the top shifts all pages, so the same row can appear on two consecutive page loads. Keyset (cursor) pagination uses the sort key of the last seen item as a WHERE clause — O(1) index seek at any depth. Implementation: fetch pageSize+1 rows; if you get n+1, there is a next page; encode the last item's sort keys as a base64 cursor for the client. Trade-offs: keyset cannot do random access (no jumping to page 50), and the cursor is tied to the sort order. Use offset for admin dashboards and small datasets; use keyset for public APIs, infinite scroll, and large tables."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.