Interview question
Implement a stable EF Core keyset page
Implements descending keyset pagination in LINQ using a compound timestamp-and-id cursor.
TL;DR
Implements descending keyset pagination in LINQ using a compound timestamp-and-id cursor.
Compound cursors, descending comparisons, stable ordering, bounded pages, projection, and index alignment.
Practice the problem like a real interview: restate, reason, implement, and test.
Return OrderFeedItem rows ordered by CreatedAtUtc DESC, Id DESC. The first page has no cursor. Later requests provide both values from the previous page's last row. Deleted orders are excluded.
After cursor (10:00, 42), include rows before 10:00 and rows at 10:00 whose id is lower than 42. A newer row inserted between requests does not shift the next page.
I build the base query and add the compound cursor predicate only for later pages. Fetching pageSize + 1 avoids a separate count query. I return the requested rows and build the next cursor from the last returned item only when the extra row proves more data exists.
public static async Task<KeysetPage<OrderFeedItem>> LoadOrderPageAsync(
AppDbContext db,
OrderCursor? cursor,
int pageSize,
CancellationToken cancellationToken)
{
pageSize = Math.Clamp(pageSize, 1, 100);
IQueryable<Order> query = db.Orders
.AsNoTracking()
.Where(order => !order.IsDeleted);
if (cursor is not null)
{
query = query.Where(order =>
order.CreatedAtUtc < cursor.CreatedAtUtc ||
(order.CreatedAtUtc == cursor.CreatedAtUtc && order.Id < cursor.OrderId));
}
var rows = await query
.OrderByDescending(order => order.CreatedAtUtc)
.ThenByDescending(order => order.Id)
.Select(order => new OrderFeedItem(
order.Id,
order.CreatedAtUtc,
order.Status))
.Take(pageSize + 1)
.ToListAsync(cancellationToken);
var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).ToList();
var last = items.LastOrDefault();
var next = hasMore && last is not null
? new OrderCursor(last.CreatedAtUtc, last.OrderId)
: null;
return new KeysetPage<OrderFeedItem>(items, next);
}
The query can use a composite index matching filter and sort direction. It reads one page plus one row rather than counting or skipping a large prefix. The cursor must be bound to the active filter set so callers cannot reuse it with different search criteria.
pageSize rows.hasMore without issuing a count query.Skip and still calling the result keyset pagination.Next in EF Core Labs: Tracked Split Query