NashTech Blog

Table of Contents

Introduction

Cursor pagination allows clients to navigate large, ordered datasets using a “cursor” that marks a position, rather than a numeric page offset. Instead of requesting “page 3,” clients request items after or before a specific boundary, typically defined by an indexed sort key like a unique identifier or timestamp. This approach improves performance and consistency in dynamic lists where items are frequently inserted or deleted.

A cursor is generally an opaque token (e.g., Base64-encoded JSON) that encodes the sort keys of the last seen item. Clients pass this token to fetch the next slice of data, making infinite scrolling and timeline-style feeds more robust and scalable.

Why cursor pagination over offset

  • Performance: Uses index range scans (e.g., WHERE created_at < :boundary) instead of expensive OFFSET skips.
  • Consistency: Reduces duplicates or missing items when data changes between requests.
  • Scalability: Ideal for high-traffic APIs and infinite scroll UIs.
  • Directional control: Supports forward and backward navigation with “after” and “before” cursors.

Tip: When sorting by a non-unique key like CreatedAt, always include a unique tiebreaker (e.g., Id) in both the ORDER BY and the cursor to avoid gaps and duplicates.

Core Design Principles

  • Stable ordering: Choose a unique, immutable, indexed sort key.
  • Opaque cursor: Use Base64-encoded JSON or a signed token.
  • Limit + 1 fetch: Retrieve one extra item to compute HasMore.
  • Consistent filters: Apply the same filters on every page.
  • Back/forward navigation: For DESC order, “after” means items strictly smaller than the boundary.

.NET Implementation Overview

API shape:

  • Endpoint: GET /api/posts?limit=20&after={cursor}
  • Response: items, pageInfo.hasMore, pageInfo.nextCursor
  • Ordering: CreatedAt DESC, Id DESC

Cursor payload:

  • Fields: createdAt, id
  • Encoding: Base64 URL-safe JSON

End-to-End .NET Example

Model

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

DbContext

public class AppDbContext : DbContext
{
    public DbSet<Post> Posts => Set<Post>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Post>()
            .HasIndex(p => new { p.CreatedAt, p.Id });
    }
}

Cursor Helper

public static class CursorHelper
{
    public static string EncodeCursor(object obj)
    {
        var json = JsonSerializer.Serialize(obj);
        return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
    }

    public static T? DecodeCursor<T>(string? cursor)
    {
        if (string.IsNullOrWhiteSpace(cursor)) return default;
        try
        {
            var bytes = Convert.FromBase64String(cursor);
            var json = Encoding.UTF8.GetString(bytes);
            return JsonSerializer.Deserialize<T>(json);
        }
        catch
        {
            return default;
        }
    }
}

DTOs

public class PaginationResponse<T>
{
    public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
    public PageInfo PageInfo { get; set; } = new();
}

public class PageInfo
{
    public bool HasMore { get; set; }
    public string? NextCursor { get; set; }
}

public class PostCursor
{
    public DateTime CreatedAt { get; set; }
    public int Id { get; set; }
}

Controller

[ApiController]
[Route("api/[controller]")]
public class PostsController : ControllerBase
{
    private readonly AppDbContext _db;

    public PostsController(AppDbContext db) => _db = db;

    [HttpGet]
    public async Task<ActionResult<PaginationResponse<object>>> Get(
        [FromQuery] int limit = 20,
        [FromQuery] string? after = null)
    {
        limit = Math.Clamp(limit, 1, 100);

        var cursor = CursorHelper.DecodeCursor<PostCursor>(after);

        var query = _db.Posts
            .OrderByDescending(p => p.CreatedAt)
            .ThenByDescending(p => p.Id)
            .AsNoTracking();

        if (cursor is not null)
        {
            query = query.Where(p =>
                p.CreatedAt < cursor.CreatedAt ||
                (p.CreatedAt == cursor.CreatedAt && p.Id < cursor.Id));
        }

        var items = await query
            .Take(limit + 1)
            .Select(p => new { p.Id, p.Title, p.CreatedAt })
            .ToListAsync();

        var hasMore = items.Count > limit;
        if (hasMore) items.RemoveAt(items.Count - 1);

        var nextCursor = items.Any()
            ? CursorHelper.EncodeCursor(new PostCursor
              {
                  CreatedAt = items.Last().CreatedAt,
                  Id = items.Last().Id
              })
            : null;

        return Ok(new PaginationResponse<object>
        {
            Items = items,
            PageInfo = new PageInfo { HasMore = hasMore, NextCursor = nextCursor }
        });
    }
}

Client Consumption Example

var http = new HttpClient { BaseAddress = new Uri("https://localhost:5001") };
string? cursor = null;

while (true)
{
    var url = "/api/posts?limit=10" + (cursor is not null ? $"&after={Uri.EscapeDataString(cursor)}" : "");
    var resp = await http.GetFromJsonAsync<ApiResponse>(url);

    foreach (var p in resp!.Items)
        Console.WriteLine($"{p.Id} | {p.CreatedAt:o} | {p.Title}");

    if (!resp.PageInfo.HasMore) break;
    cursor = resp.PageInfo.NextCursor;
}

Handling Edge Cases

  • Duplicate sort keys: include a secondary unique key.
  • Invalid cursor: fail gracefully.
  • Filter changes: don’t mix filters between pages.
  • Time zones: normalize timestamps to UTC.
  • Backward pagination: invert the range for “before” semantics.

SQL Patterns

-- Descending order (newest first), forward pagination:
SELECT id, title, created_at
FROM posts
WHERE (created_at < :cursor_created_at)
   OR (created_at = :cursor_created_at AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit_plus_one;

Conclusion

Cursor pagination provides a robust, scalable alternative to offset-based paging for large or dynamic datasets. By anchoring navigation to a stable ordered boundary and returning opaque cursor tokens, your .NET APIs can deliver consistent results, better performance, and smooth infinite scrolling experiences.

Picture of Dung Nguyen

Dung Nguyen

Suggested Article

Scroll to Top