https://learn.microsoft.com/en-us/ef/core/
Cursor pagination is a powerful technique that uses a pointer (cursor) to track position in a dataset, rather than using page numbers or offsets. Unlike traditional offset-based pagination, cursor pagination provides better performance, consistency, and scalability when dealing with frequently changing data.
When you paginate with cursors, the system:
Critical advantage: Cursor pagination maintains stable references even as data changes, preventing duplicate or missing items between requests.
Traditional offset-based pagination (using SKIP and TAKE) degrades significantly as you paginate deeper. The database must scan and skip all previous records, making page 1000 much slower than page 1.
If data is added or removed between requests, offset pagination can cause users to see duplicate items or miss items entirely. Cursor pagination maintains position regardless of data changes.
Deep pagination with offsets puts unnecessary load on database resources. Cursor pagination uses indexed lookups (WHERE id > cursor) which remain fast regardless of dataset size.
Ideal for infinite scroll, real-time feeds, and mobile applications where users continuously load more content without explicit page navigation.
Queries execute consistently fast whether fetching the first page or the millionth, reducing server load and improving response times.
Perfect for scenarios where data is frequently added, updated, or deleted, such as social media feeds, live dashboards, or streaming applications.
Imagine you have a products table with 1 million records:
Traditional Offset Pagination (Inefficient):
-- Page 1: Fast
SELECT * FROM Products ORDER BY Id OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;
-- Page 100: Slow - must skip 1,980 rows
SELECT * FROM Products ORDER BY Id OFFSET 1980 ROWS FETCH NEXT 20 ROWS ONLY;
-- Page 10,000: Very slow - must skip 199,980 rows
SELECT * FROM Products ORDER BY Id OFFSET 199980 ROWS FETCH NEXT 20 ROWS ONLY;
Cursor Pagination (Efficient):
-- First page: Fast
SELECT TOP 20 * FROM Products ORDER BY Id;
-- Returns cursor: last item's Id = 20
-- Next page: Still fast - uses index
SELECT TOP 20 * FROM Products WHERE Id > 20 ORDER BY Id;
-- Returns cursor: last item's Id = 40
-- Any page: Always fast - direct index lookup
SELECT TOP 20 * FROM Products WHERE Id > 199980 ORDER BY Id;
The difference: Every cursor pagination query is as fast as the first page because it uses indexed WHERE clauses instead of expensive OFFSET operations.
A cursor is a reference point that marks your position in a dataset. Common cursor types include:
"12345""2025-01-15T10:30:00Z"{ "timestamp": "2025-01-15T10:30:00Z", "id": 12345 }"eyJ0aW1lc3RhbXAiOiIyMDI1LTAxLTE1VDEwOjMwOjAwWiIsImlkIjoxMjM0NX0="Best practice: Use composite cursors (timestamp + ID) for tie-breaking when multiple records share the same timestamp.
Cursor pagination supports bidirectional navigation:
Forward Pagination:
WHERE (CreatedAt > cursor.Timestamp)
OR (CreatedAt = cursor.Timestamp AND Id > cursor.Id)
ORDER BY CreatedAt ASC, Id ASC
Backward Pagination:
WHERE (CreatedAt < cursor.Timestamp)
OR (CreatedAt = cursor.Timestamp AND Id < cursor.Id)
ORDER BY CreatedAt DESC, Id DESC
Always encode cursors to prevent tampering and simplify API design:
public class TimestampCursor
{
public DateTime Timestamp { get; set; }
public int Id { get; set; }
public string Encode()
{
var json = JsonSerializer.Serialize(this);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
public static TimestampCursor? Decode(string cursor)
{
try
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
return JsonSerializer.Deserialize<TimestampCursor>(json);
}
catch
{
return null;
}
}
}
The most straightforward approach using auto-incrementing IDs:
public class ProductService
{
private readonly ApplicationDbContext _context;
public ProductService(ApplicationDbContext context)
{
_context = context;
}
public async Task<CursorPageResponse<Product>> GetProductsAsync(
CursorPageRequest request)
{
var query = _context.Products.AsQueryable();
if (!string.IsNullOrEmpty(request.Cursor))
{
if (int.TryParse(request.Cursor, out int cursorId))
{
if (request.Forward)
{
query = query.Where(p => p.Id > cursorId);
}
else
{
query = query.Where(p => p.Id < cursorId);
}
}
}
// Order by ID
query = request.Forward
? query.OrderBy(p => p.Id)
: query.OrderByDescending(p => p.Id);
// Fetch one extra item to check if there's a next page
var items = await query
.Take(request.PageSize + 1)
.ToListAsync();
var hasMore = items.Count > request.PageSize;
if (hasMore)
{
items = items.Take(request.PageSize).ToList();
}
// If we were going backward, reverse the items
if (!request.Forward)
{
items.Reverse();
}
return new CursorPageResponse<Product>
{
Items = items,
NextCursor = items.Any() ? items.Last().Id.ToString() : null,
PreviousCursor = items.Any() ? items.First().Id.ToString() : null,
HasNextPage = request.Forward && hasMore,
HasPreviousPage = !request.Forward && hasMore,
TotalRetrieved = items.Count
};
}
}
What happens:
For scenarios where ordering by creation time is more appropriate:
public async Task<CursorPageResponse<Product>> GetProductsByTimeAsync(
CursorPageRequest request)
{
var query = _context.Products.AsQueryable();
if (!string.IsNullOrEmpty(request.Cursor))
{
var cursor = TimestampCursor.Decode(request.Cursor);
if (cursor != null)
{
if (request.Forward)
{
query = query.Where(p =>
p.CreatedAt > cursor.Timestamp ||
(p.CreatedAt == cursor.Timestamp && p.Id > cursor.Id));
}
else
{
query = query.Where(p =>
p.CreatedAt < cursor.Timestamp ||
(p.CreatedAt == cursor.Timestamp && p.Id < cursor.Id));
}
}
}
query = request.Forward
? query.OrderBy(p => p.CreatedAt).ThenBy(p => p.Id)
: query.OrderByDescending(p => p.CreatedAt).ThenByDescending(p => p.Id);
var items = await query
.Take(request.PageSize + 1)
.ToListAsync();
var hasMore = items.Count > request.PageSize;
if (hasMore)
{
items = items.Take(request.PageSize).ToList();
}
if (!request.Forward)
{
items.Reverse();
}
string? CreateCursor(Product product) =>
new TimestampCursor
{
Timestamp = product.CreatedAt,
Id = product.Id
}.Encode();
return new CursorPageResponse<Product>
{
Items = items,
NextCursor = items.Any() ? CreateCursor(items.Last()) : null,
PreviousCursor = items.Any() ? CreateCursor(items.First()) : null,
HasNextPage = request.Forward && hasMore,
HasPreviousPage = !request.Forward && hasMore,
TotalRetrieved = items.Count
};
}
Key difference: Uses composite cursor (timestamp + ID) to handle records with identical timestamps.
For maximum performance and control, Dapper provides direct SQL access:
public class ProductRepository
{
private readonly IDbConnection _connection;
public ProductRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<CursorPageResponse<Product>> GetProductsWithDapperAsync(
CursorPageRequest request)
{
var parameters = new DynamicParameters();
parameters.Add("@PageSize", request.PageSize + 1);
string cursorCondition = string.Empty;
string orderBy = request.Forward ? "ASC" : "DESC";
if (!string.IsNullOrEmpty(request.Cursor))
{
if (int.TryParse(request.Cursor, out int cursorId))
{
cursorCondition = request.Forward
? "WHERE Id > @CursorId"
: "WHERE Id < @CursorId";
parameters.Add("@CursorId", cursorId);
}
}
var sql = $@"
SELECT TOP (@PageSize)
Id,
Name,
Price,
CreatedAt,
UpdatedAt
FROM Products
{cursorCondition}
ORDER BY Id {orderBy}";
var items = (await _connection.QueryAsync<Product>(sql, parameters))
.ToList();
var hasMore = items.Count > request.PageSize;
if (hasMore)
{
items = items.Take(request.PageSize).ToList();
}
if (!request.Forward)
{
items.Reverse();
}
return new CursorPageResponse<Product>
{
Items = items,
NextCursor = items.Any() ? items.Last().Id.ToString() : null,
PreviousCursor = items.Any() ? items.First().Id.ToString() : null,
HasNextPage = request.Forward && hasMore,
HasPreviousPage = !request.Forward && hasMore,
TotalRetrieved = items.Count
};
}
public async Task<CursorPageResponse<Product>> GetProductsByTimestampDapperAsync(
CursorPageRequest request)
{
var parameters = new DynamicParameters();
parameters.Add("@PageSize", request.PageSize + 1);
string cursorCondition = string.Empty;
string orderBy = request.Forward
? "ORDER BY CreatedAt ASC, Id ASC"
: "ORDER BY CreatedAt DESC, Id DESC";
if (!string.IsNullOrEmpty(request.Cursor))
{
var cursor = TimestampCursor.Decode(request.Cursor);
if (cursor != null)
{
if (request.Forward)
{
cursorCondition = @"
WHERE (CreatedAt > @CursorTimestamp)
OR (CreatedAt = @CursorTimestamp AND Id > @CursorId)";
}
else
{
cursorCondition = @"
WHERE (CreatedAt < @CursorTimestamp)
OR (CreatedAt = @CursorTimestamp AND Id < @CursorId)";
}
parameters.Add("@CursorTimestamp", cursor.Timestamp);
parameters.Add("@CursorId", cursor.Id);
}
}
var sql = $@"
SELECT TOP (@PageSize)
Id,
Name,
Price,
CreatedAt,
UpdatedAt
FROM Products
{cursorCondition}
{orderBy}";
var items = (await _connection.QueryAsync<Product>(sql, parameters))
.ToList();
var hasMore = items.Count > request.PageSize;
if (hasMore)
{
items = items.Take(request.PageSize).ToList();
}
if (!request.Forward)
{
items.Reverse();
}
string? CreateCursor(Product product) =>
new TimestampCursor
{
Timestamp = product.CreatedAt,
Id = product.Id
}.Encode();
return new CursorPageResponse<Product>
{
Items = items,
NextCursor = items.Any() ? CreateCursor(items.Last()) : null,
PreviousCursor = items.Any() ? CreateCursor(items.First()) : null,
HasNextPage = request.Forward && hasMore,
HasPreviousPage = !request.Forward && hasMore,
TotalRetrieved = items.Count
};
}
}
Performance benefit: Dapper executes raw SQL with minimal overhead, typically 30-50% faster than EF Core for read operations.
Build dynamic queries with multiple filters:
public async Task<CursorPageResponse<Product>> GetProductsWithFiltersDapperAsync(
CursorPageRequest request,
string? searchTerm = null,
decimal? minPrice = null,
decimal? maxPrice = null,
int? categoryId = null)
{
var parameters = new DynamicParameters();
parameters.Add("@PageSize", request.PageSize + 1);
var conditions = new List<string>();
if (!string.IsNullOrEmpty(searchTerm))
{
conditions.Add("Name LIKE @SearchTerm");
parameters.Add("@SearchTerm", $"%{searchTerm}%");
}
if (minPrice.HasValue)
{
conditions.Add("Price >= @MinPrice");
parameters.Add("@MinPrice", minPrice.Value);
}
if (maxPrice.HasValue)
{
conditions.Add("Price <= @MaxPrice");
parameters.Add("@MaxPrice", maxPrice.Value);
}
if (categoryId.HasValue)
{
conditions.Add("CategoryId = @CategoryId");
parameters.Add("@CategoryId", categoryId.Value);
}
if (!string.IsNullOrEmpty(request.Cursor))
{
var cursor = TimestampCursor.Decode(request.Cursor);
if (cursor != null)
{
if (request.Forward)
{
conditions.Add(
"(CreatedAt > @CursorTimestamp OR " +
"(CreatedAt = @CursorTimestamp AND Id > @CursorId))");
}
else
{
conditions.Add(
"(CreatedAt < @CursorTimestamp OR " +
"(CreatedAt = @CursorTimestamp AND Id < @CursorId))");
}
parameters.Add("@CursorTimestamp", cursor.Timestamp);
parameters.Add("@CursorId", cursor.Id);
}
}
var whereClause = conditions.Any()
? "WHERE " + string.Join(" AND ", conditions)
: string.Empty;
var orderBy = request.Forward
? "ORDER BY CreatedAt ASC, Id ASC"
: "ORDER BY CreatedAt DESC, Id DESC";
var sql = $@"
SELECT TOP (@PageSize)
Id,
Name,
Price,
CategoryId,
CreatedAt,
UpdatedAt
FROM Products
{whereClause}
{orderBy}";
var items = (await _connection.QueryAsync<Product>(sql, parameters))
.ToList();
return BuildPageResponse(items, request);
}
public class PerformanceBenchmark
{
private readonly IDbConnection _connection;
private readonly ApplicationDbContext _context;
[Benchmark]
public async Task<List<Product>> EFCore_CursorPagination()
{
var result = await _context.Products
.Where(p => p.Id > 1000)
.OrderBy(p => p.Id)
.Take(20)
.ToListAsync();
return result;
}
[Benchmark]
public async Task<List<Product>> Dapper_CursorPagination()
{
var sql = @"
SELECT TOP 20
Id, Name, Price, CreatedAt, UpdatedAt
FROM Products
WHERE Id > @CursorId
ORDER BY Id ASC";
var result = await _connection.QueryAsync<Product>(
sql,
new { CursorId = 1000 });
return result.ToList();
}
}
Use Dapper when:
Use Entity Framework Core when:
Always use indexed columns as cursor fields to maintain performance.
Without proper indexes, cursor pagination loses its performance advantage and may even be slower than offset pagination.
CREATE INDEX IX_Products_Id ON Products(Id);
CREATE INDEX IX_Products_CreatedAt_Id ON Products(CreatedAt, Id);
CREATE INDEX IX_Products_Category_CreatedAt_Id
ON Products(CategoryId, CreatedAt, Id);
CREATE INDEX IX_Products_Name_CreatedAt_Id
ON Products(Name, CreatedAt, Id);
✅ Infinite scroll interfaces
✅ Real-time feeds and timelines
✅ Mobile applications
✅ Large datasets
✅ Frequently changing data
✅ Performance-critical APIs
✅ Streaming data applications
✅ Activity logs and audit trails
✅ Small, static datasets
✅ Traditional page-based navigation
✅ Need to show total page count
✅ Users need to jump to arbitrary pages
✅ Administrative interfaces
✅ Reports with known data boundaries
✅ Data exports or batch processing
Join related tables while maintaining cursor pagination:
public class ProductWithCategory
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
public Category Category { get; set; }
}
public async Task<CursorPageResponse<ProductWithCategory>>
GetProductsWithCategoryAsync(CursorPageRequest request)
{
var parameters = new DynamicParameters();
parameters.Add("@PageSize", request.PageSize + 1);
string cursorCondition = string.Empty;
if (!string.IsNullOrEmpty(request.Cursor))
{
var cursor = TimestampCursor.Decode(request.Cursor);
if (cursor != null)
{
cursorCondition = request.Forward
? "WHERE (p.CreatedAt > @CursorTimestamp OR " +
"(p.CreatedAt = @CursorTimestamp AND p.Id > @CursorId))"
: "WHERE (p.CreatedAt < @CursorTimestamp OR " +
"(p.CreatedAt = @CursorTimestamp AND p.Id < @CursorId))";
parameters.Add("@CursorTimestamp", cursor.Timestamp);
parameters.Add("@CursorId", cursor.Id);
}
}
var orderBy = request.Forward
? "ORDER BY p.CreatedAt ASC, p.Id ASC"
: "ORDER BY p.CreatedAt DESC, p.Id DESC";
var sql = $@"
SELECT TOP (@PageSize)
p.Id,
p.Name,
p.Price,
p.CreatedAt,
c.Id,
c.Name
FROM Products p
INNER JOIN Categories c ON p.CategoryId = c.Id
{cursorCondition}
{orderBy}";
var items = (await _connection.QueryAsync<ProductWithCategory, Category, ProductWithCategory>(
sql,
(product, category) =>
{
product.Category = category;
return product;
},
parameters,
splitOn: "Id"))
.ToList();
return BuildPageResponse(items, request);
}
Optimize frequently-executed queries:
private static readonly Func<ApplicationDbContext, int, int, Task<List<Product>>>
_compiledQuery = EF.CompileAsyncQuery(
(ApplicationDbContext context, int cursorId, int pageSize) =>
context.Products
.Where(p => p.Id > cursorId)
.OrderBy(p => p.Id)
.Take(pageSize)
.ToList()
);
public async Task<List<Product>> GetProductsOptimizedAsync(int cursorId, int pageSize)
{
return await _compiledQuery(_context, cursorId, pageSize);
}
Always validate cursor input to prevent errors:
public class CursorValidator
{
public static bool IsValidCursor(string cursor)
{
if (string.IsNullOrEmpty(cursor)) return true;
try
{
var decoded = TimestampCursor.Decode(cursor);
return decoded != null &&
decoded.Timestamp <= DateTime.UtcNow &&
decoded.Id > 0;
}
catch
{
return false;
}
}
}
Enforce reasonable limits:
public class PaginationSettings
{
public int DefaultPageSize { get; set; } = 20;
public int MinPageSize { get; set; } = 1;
public int MaxPageSize { get; set; } = 100;
public int NormalizePageSize(int requestedSize)
{
if (requestedSize < MinPageSize) return MinPageSize;
if (requestedSize > MaxPageSize) return MaxPageSize;
return requestedSize;
}
}
let currentCursor = null;
let isLoading = false;
async function loadMoreProducts() {
if (isLoading) return;
isLoading = true;
const response = await fetch(
`/api/products?cursor=${currentCursor || ''}&pageSize=20`
);
const data = await response.json();
data.items.forEach(item => appendProductToList(item));
currentCursor = data.nextCursor;
if (!data.hasNextPage) {
disableInfiniteScroll();
}
isLoading = false;
}
window.addEventListener('scroll', () => {
if (isNearBottom() && currentCursor) {
loadMoreProducts();
}
});
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ProductRepository _repository;
[HttpGet]
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "cursor", "pageSize" })]
public async Task<ActionResult<CursorPageResponse<Product>>> GetProducts(
[FromQuery] string? cursor,
[FromQuery] int pageSize = 20,
[FromQuery] bool forward = true)
{
if (pageSize < 1 || pageSize > 100)
{
return BadRequest("Page size must be between 1 and 100");
}
var request = new CursorPageRequest
{
Cursor = cursor,
PageSize = pageSize,
Forward = forward
};
var result = await _repository.GetProductsByTimestampDapperAsync(request);
return Ok(result);
}
}
public class ProductQueries
{
[UseDbContext(typeof(ApplicationDbContext))]
[UsePaging]
[UseFiltering]
[UseSorting]
public IQueryable<Product> GetProducts(
[ScopedService] ApplicationDbContext context)
{
return context.Products;
}
}
public class CursorPaginationTests
{
[Fact]
public async Task GetProducts_WithCursor_ReturnsCorrectPage()
{
var context = GetInMemoryContext();
await SeedProducts(context, 50);
var service = new ProductService(context);
var firstPage = await service.GetProductsAsync(new CursorPageRequest
{
PageSize = 10
});
Assert.Equal(10, firstPage.Items.Count);
Assert.True(firstPage.HasNextPage);
Assert.NotNull(firstPage.NextCursor);
var secondPage = await service.GetProductsAsync(new CursorPageRequest
{
Cursor = firstPage.NextCursor,
PageSize = 10
});
Assert.Equal(10, secondPage.Items.Count);
Assert.True(secondPage.HasNextPage);
var firstPageIds = firstPage.Items.Select(p => p.Id).ToHashSet();
var secondPageIds = secondPage.Items.Select(p => p.Id).ToHashSet();
Assert.Empty(firstPageIds.Intersect(secondPageIds));
}
[Fact]
public async Task GetProducts_WithDataChanges_MaintainsConsistency()
{
var context = GetInMemoryContext();
await SeedProducts(context, 30);
var service = new ProductService(context);
var firstPage = await service.GetProductsAsync(new CursorPageRequest
{
PageSize = 10
});
await AddNewProducts(context, 5);
var secondPage = await service.GetProductsAsync(new CursorPageRequest
{
Cursor = firstPage.NextCursor,
PageSize = 10
});
var firstPageIds = firstPage.Items.Select(p => p.Id).ToHashSet();
var secondPageIds = secondPage.Items.Select(p => p.Id).ToHashSet();
Assert.Empty(firstPageIds.Intersect(secondPageIds));
}
}
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IDbConnection>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
var connectionString = configuration.GetConnectionString("DefaultConnection");
return new SqlConnection(connectionString);
});
services.AddScoped<IDbConnectionFactory, SqlConnectionFactory>();
services.AddScoped<ProductRepository>();
}
public interface IDbConnectionFactory
{
IDbConnection CreateConnection();
}
public class SqlConnectionFactory : IDbConnectionFactory
{
private readonly string _connectionString;
public SqlConnectionFactory(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection");
}
public IDbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}
Problem: Cursor pagination is slow despite correct implementation.
Solution: Ensure composite indexes exist on cursor fields:
-- Wrong: Single column index
CREATE INDEX IX_Products_CreatedAt ON Products(CreatedAt);
-- Correct: Composite index with tie-breaker
CREATE INDEX IX_Products_CreatedAt_Id ON Products(CreatedAt, Id);
Problem: Items appear in wrong order when paginating backward.
Solution: Remember to reverse items when going backward:
if (!request.Forward)
{
items.Reverse();
}
Problem: Duplicate or missing items when multiple records share the same timestamp.
Solution: Always use composite cursors:
// Include ID as tie-breaker
WHERE (CreatedAt > cursor.Timestamp)
OR (CreatedAt = cursor.Timestamp AND Id > cursor.Id)
Problem: Raw IDs in cursors leak database structure.
Solution: Always encode cursors:
// Encode cursor before returning
var encoded = Convert.ToBase64String(
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(cursor))
);
// Basic Entity Framework Core pattern
var query = _context.Products
.Where(p => p.Id > cursorId)
.OrderBy(p => p.Id)
.Take(pageSize + 1);
// Basic Dapper pattern
var sql = @"
SELECT TOP (@PageSize)
Id, Name, Price
FROM Products
WHERE Id > @CursorId
ORDER BY Id ASC";
// Composite cursor pattern
WHERE (CreatedAt > @Timestamp)
OR (CreatedAt = @Timestamp AND Id > @Id)
ORDER BY CreatedAt ASC, Id ASC
// Required index pattern
CREATE INDEX IX_Table_SortColumn_TieBreaker
ON TableName(SortColumn, TieBreakerColumn);
Cursor pagination is an essential technique for building scalable, performant applications in C#. By using cursors instead of offsets, you provide a better user experience with faster queries, consistent results, and efficient resource usage.
Key takeaways: