Cursor-Based Pagination & Sorting
In the previous presentation, you built dynamic query filters. Now you will add cursor-based pagination to those queries, implement configurable sorting, and set up response caching headers. By the end, your search endpoint will return paginated results with full navigation metadata.
The Pagination Response DTO
Define a generic container for paginated results that works with any entity type:
csharp1public class PagedResult<T>2{3 public List<T> Items { get; set; } = [];4 public int TotalCount { get; set; }5 public int PageSize { get; set; }6 public string? Cursor { get; set; }7 public string? NextCursor { get; set; }8 public bool HasNextPage { get; set; }9}
This DTO gives clients everything they need to render results and navigate pages:
- Items: The current page of results
- TotalCount: Total number of matching records (useful for showing "1-20 of 1,547")
- PageSize: The actual page size used (after clamping)
- Cursor: The cursor representing the start of the current page
- NextCursor: The cursor to fetch the next page (null if no more pages)
- HasNextPage: A convenience boolean so clients do not need to null-check NextCursor
How Cursor-Based Pagination Works
Cursor pagination uses a value from the last item on the current page to determine where the next page starts. The cursor encodes enough information to uniquely identify a position in the sorted result set.
The Cursor Value
For a query sorted by CreatedAt DESC, the cursor must encode:
- The
CreatedAtvalue of the last item on the current page - The
Idof the last item (as a tiebreaker for items with the same timestamp)
Encoding both values ensures a unique cursor even when multiple parcels share the same CreatedAt value.
Encoding and Decoding
Use Base64 encoding to create opaque cursor strings. The client treats the cursor as an opaque token and passes it back unchanged.
csharp1public static class CursorHelper2{3 public static string Encode(DateTime sortValue, Guid id)4 {5 var raw = $"{sortValue:O}|{id}";6 return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw));7 }89 public static (DateTime SortValue, Guid Id) Decode(string cursor)10 {11 var raw = Encoding.UTF8.GetString(12 Convert.FromBase64String(cursor));13 var parts = raw.Split('|');1415 return (16 DateTime.Parse(parts[0], null,17 System.Globalization.DateTimeStyles.RoundtripKind),18 Guid.Parse(parts[1])19 );20 }21}
A cursor looks like MjAyNS0wMi0xNVQxMDozMDowMC4wMDAwMDAwWnw0YTNiLi4u. The client cannot interpret it, which gives you freedom to change the encoding later without breaking the API contract.
Applying the Cursor to the Query
When the client provides a cursor, add a WHERE clause that skips all rows before the cursor position:
csharp1private static IQueryable<Parcel> ApplyCursor(2 IQueryable<Parcel> query,3 ParcelSearchParams searchParams)4{5 if (string.IsNullOrEmpty(searchParams.Cursor))6 return query;78 var (sortValue, lastId) = CursorHelper.Decode(searchParams.Cursor);910 if (searchParams.SortDescending)11 {12 // For DESC order: get rows with smaller sort values,13 // or same sort value but smaller Id14 query = query.Where(p =>15 p.CreatedAt < sortValue ||16 (p.CreatedAt == sortValue && p.Id.CompareTo(lastId) < 0));17 }18 else19 {20 // For ASC order: get rows with larger sort values,21 // or same sort value but larger Id22 query = query.Where(p =>23 p.CreatedAt > sortValue ||24 (p.CreatedAt == sortValue && p.Id.CompareTo(lastId) > 0));25 }2627 return query;28}
This is the key advantage of cursor pagination: the WHERE clause uses an indexed comparison (< or >) instead of OFFSET. The database can seek directly to the right position using an index, regardless of how deep into the result set you are.
Implementing Sorting
Support multiple sort fields with a fallback to the default sort order. The sort field determines which column the cursor references.
csharp1private static IQueryable<Parcel> ApplySorting(2 IQueryable<Parcel> query,3 ParcelSearchParams searchParams)4{5 var sortBy = searchParams.SortBy?.ToLower() ?? "createdat";67 query = (sortBy, searchParams.SortDescending) switch8 {9 ("createdat", true) => query10 .OrderByDescending(p => p.CreatedAt)11 .ThenByDescending(p => p.Id),12 ("createdat", false) => query13 .OrderBy(p => p.CreatedAt)14 .ThenBy(p => p.Id),15 ("estimateddeliverydate", true) => query16 .OrderByDescending(p => p.EstimatedDeliveryDate)17 .ThenByDescending(p => p.Id),18 ("estimateddeliverydate", false) => query19 .OrderBy(p => p.EstimatedDeliveryDate)20 .ThenBy(p => p.Id),21 ("status", true) => query22 .OrderByDescending(p => p.Status)23 .ThenByDescending(p => p.Id),24 ("status", false) => query25 .OrderBy(p => p.Status)26 .ThenBy(p => p.Id),27 _ => query28 .OrderByDescending(p => p.CreatedAt)29 .ThenByDescending(p => p.Id)30 };3132 return query;33}
The Tiebreaker Column
Every sort configuration includes .ThenBy(p => p.Id) or .ThenByDescending(p => p.Id). This tiebreaker is mandatory for cursor-based pagination because:
- Multiple parcels can share the same
CreatedAtvalue - Without a tiebreaker, the cursor cannot uniquely identify a position
- The
Idcolumn (a GUID or auto-increment) is always unique
Handling Different Sort Fields with Cursors
When the sort field changes, the cursor encoding must match. A more robust cursor implementation encodes the sort field along with the value:
csharp1public static class CursorHelper2{3 public static string Encode(string sortField, string sortValue, Guid id)4 {5 var raw = $"{sortField}|{sortValue}|{id}";6 return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw));7 }89 public static (string SortField, string SortValue, Guid Id)10 Decode(string cursor)11 {12 var raw = Encoding.UTF8.GetString(13 Convert.FromBase64String(cursor));14 var parts = raw.Split('|', 3);15 return (parts[0], parts[1], Guid.Parse(parts[2]));16 }17}
This approach ensures a cursor generated for a CreatedAt sort is not accidentally used with an EstimatedDeliveryDate sort. If the sort field in the cursor does not match the current request, you can either ignore the cursor (start from the beginning) or return a validation error.
Building the Paged Result
After executing the query, construct the response with pagination metadata:
csharp1private PagedResult<ParcelDto> BuildPagedResult(2 List<Parcel> parcels,3 ParcelSearchParams searchParams,4 int totalCount)5{6 // We fetched pageSize + 1 to detect if there's a next page7 var hasNextPage = parcels.Count > searchParams.PageSize;89 // Take only the requested page size10 var items = parcels11 .Take(searchParams.PageSize)12 .Select(p => MapToDto(p))13 .ToList();1415 string? nextCursor = null;16 if (hasNextPage && items.Count > 0)17 {18 var lastItem = parcels[searchParams.PageSize - 1];19 nextCursor = CursorHelper.Encode(20 lastItem.CreatedAt, lastItem.Id);21 }2223 return new PagedResult<ParcelDto>24 {25 Items = items,26 TotalCount = totalCount,27 PageSize = searchParams.PageSize,28 Cursor = searchParams.Cursor,29 NextCursor = nextCursor,30 HasNextPage = hasNextPage31 };32}
The "Fetch One Extra" Trick
Notice that the query fetches pageSize + 1 items:
csharp1var parcels = await query2 .Take(searchParams.PageSize + 1)3 .ToListAsync();
If you get pageSize + 1 rows back, there are more results after this page. If you get pageSize or fewer rows, this is the last page. This avoids a separate query to check whether a next page exists.
The extra item is not included in the response. Only the first pageSize items are returned.
Complete Search Endpoint
Here is the full controller action combining filters, sorting, pagination, and cache headers:
csharp1[ApiController]2[Route("api/[controller]")]3public class ParcelsController : ControllerBase4{5 private readonly ParcelTrackingDbContext _context;67 public ParcelsController(ParcelTrackingDbContext context)8 {9 _context = context;10 }1112 [HttpGet]13 [ResponseCache(Duration = 30, VaryByQueryKeys = ["*"])]14 public async Task<ActionResult<PagedResult<ParcelDto>>> SearchParcels(15 [FromQuery] ParcelSearchParams searchParams)16 {17 // Validate and clamp page size18 searchParams.PageSize = Math.Clamp(19 searchParams.PageSize, 1, 100);2021 // Build base query22 IQueryable<Parcel> query = _context.Parcels23 .Include(p => p.ShipperAddress)24 .Include(p => p.RecipientAddress);2526 // Apply dynamic filters27 query = ApplyFilters(query, searchParams);2829 // Get total count (before pagination)30 var totalCount = await query.CountAsync();3132 // Apply sorting33 query = ApplySorting(query, searchParams);3435 // Apply cursor36 query = ApplyCursor(query, searchParams);3738 // Fetch one extra to detect next page39 var parcels = await query40 .Take(searchParams.PageSize + 1)41 .ToListAsync();4243 // Build response44 var result = BuildPagedResult(45 parcels, searchParams, totalCount);4647 return Ok(result);48 }49}
The Execution Order Matters
The operations must happen in this specific order:
- Filter - Narrow down the dataset
- Count - Get total matching records (needed in response metadata)
- Sort - Establish the order for pagination
- Cursor - Skip to the correct position
- Take - Limit to page size + 1
- Materialize - Execute the query and load results
If you count after applying the cursor, you get the count of remaining items, not the total count. If you sort after applying the cursor, the cursor position is meaningless.
Adding Response Cache Headers
The [ResponseCache] attribute adds Cache-Control headers to the response. For more control, set headers manually:
csharp1[HttpGet]2public async Task<ActionResult<PagedResult<ParcelDto>>> SearchParcels(3 [FromQuery] ParcelSearchParams searchParams)4{5 // ... query logic ...67 Response.Headers.CacheControl = "public, max-age=30";8 Response.Headers.Vary = "Accept, Accept-Encoding";910 return Ok(result);11}
Response Caching Middleware
To enable server-side caching, add the response caching middleware in Program.cs:
csharp1builder.Services.AddResponseCaching();23var app = builder.Build();45app.UseResponseCaching();
This middleware caches responses based on the Cache-Control headers and Vary settings. Subsequent identical requests (same URL, same query parameters, same Accept header) are served from the cache without hitting the database.
VaryByQueryKeys
The [ResponseCache] attribute supports VaryByQueryKeys to create separate cache entries for each unique combination of query parameters:
csharp1[ResponseCache(Duration = 30, VaryByQueryKeys = ["*"])]
The "*" wildcard means every distinct query string gets its own cache entry. A request for ?status=InTransit&pageSize=10 is cached separately from ?status=Delivered&pageSize=20.
Page Size Clamping
Always enforce page size boundaries on the server side:
csharp1searchParams.PageSize = Math.Clamp(searchParams.PageSize, 1, 100);
Math.Clamp keeps the value between 1 and 100:
| Client Requests | Server Uses |
|---|---|
pageSize=0 | 1 |
pageSize=10 | 10 |
pageSize=50 | 50 |
pageSize=500 | 100 |
pageSize=-1 | 1 |
| (not provided) | 20 (default from DTO) |
This protects the server from clients requesting excessively large pages while being lenient about input.
An Example Response
A typical paginated search response looks like this:
json1{2 "items": [3 {4 "id": "a1b2c3d4-...",5 "trackingNumber": "PKG-20250215-X7K9M2",6 "status": "InTransit",7 "serviceType": "Express",8 "shipperCity": "New York",9 "recipientCity": "Berlin",10 "createdAt": "2025-02-15T10:30:00Z",11 "estimatedDeliveryDate": "2025-02-17T18:00:00Z"12 },13 {14 "id": "e5f6g7h8-...",15 "trackingNumber": "PKG-20250215-B3N5P8",16 "status": "InTransit",17 "serviceType": "Express",18 "shipperCity": "Chicago",19 "recipientCity": "Munich",20 "createdAt": "2025-02-15T09:15:00Z",21 "estimatedDeliveryDate": "2025-02-18T12:00:00Z"22 }23 ],24 "totalCount": 347,25 "pageSize": 20,26 "cursor": null,27 "nextCursor": "MjAyNS0wMi0xNVQwODo0NTowMC4wMDAwMDAwWnxmOWUxYTIz...",28 "hasNextPage": true29}
The client renders the 20 items and uses nextCursor to fetch the next page:
GET /api/parcels?status=InTransit&serviceType=Express&cursor=MjAyNS0wMi0xNVQwODo0NTowMC4wMDAwMDAwWnxmOWUxYTIz...
Common Pitfalls
Forgetting the Tiebreaker Sort
Without .ThenBy(p => p.Id), parcels with the same CreatedAt can appear on multiple pages or be skipped entirely. Always include a unique tiebreaker column.
Counting After Cursor
If you run CountAsync() after applying the cursor WHERE clause, you count only the remaining items, not the total. Always count before applying the cursor.
Inconsistent Sort and Cursor Direction
If the sort is DESC but the cursor uses > instead of <, the cursor skips in the wrong direction. Match the comparison operator to the sort direction.
Cursor Tampering
Clients might send invalid or malformed cursors. Wrap the decode in a try-catch and return the first page if the cursor is invalid:
csharp1try2{3 var (sortValue, lastId) = CursorHelper.Decode(searchParams.Cursor);4 // apply cursor filter5}6catch7{8 // Invalid cursor, start from the beginning9}
Summary
In this presentation, you learned:
- Cursor-based pagination uses a
WHEREclause with indexed comparisons instead ofOFFSET - The cursor encodes the sort field value and the record ID for unique positioning
- Base64 encoding creates opaque cursor tokens that clients pass back unchanged
- Fetching
pageSize + 1detects the next page without a separate count query - Sorting requires a tiebreaker column (Id) for deterministic ordering
- The execution order is: filter, count, sort, cursor, take, materialize
Math.Clampenforces page size boundaries without rejecting requests- Response caching with
Cache-ControlandVaryByQueryKeysreduces database load - Always handle invalid cursors gracefully by falling back to the first page