Destino Dev

Blog

Implementando paginação cursor-based com EF Core e Minimal APIs (passo a passo)

Por John Pires · 09 de março de 2026

1) Problema real que resolve

Paginação é um requisito recorrente em APIs que retornam coleções grandes — feeds, logs, listas de produtos. O problema que a paginação cursor-based resolve é a escala e a consistência da navegação em grandes conjuntos de dados: evitar saltos lentos de OFFSET, reduzir custo de consultas e prevenir lacunas ou duplicatas quando os dados mudam entre requisições.

Em aplicações com tráfego significativo e tabelas com dezenas de milhões de linhas, usar OFFSET+LIMIT causa degradações severas: queries percorrendo muitos registros para alcançar a página N, índices subutilizados e picos de latência. Cursor-based pagination (também chamado keyset pagination) usa um cursor opaco que representa a posição no conjunto ordenado e permite consultas com filtros simples e eficientes que aproveitam índices.

Este artigo foca em implementar cursor-based com Entity Framework Core e Minimal APIs no ASP.NET Core. A abordagem é prática: modelagem de cursor, composição de filtros em LINQ, geração segura de cursor opaco (base64+assinatura opcional), e contratos de API para front-end (Blazor, SPA ou mobile).

Quando preferir cursor-based

Use cursor-based quando você precisar de:

  • Baixa latência em páginas profundas
  • Consumo eficiente de índices (ordens por colunas indexadas)
  • Navegação estável mesmo com inserções ou deleções entre requisições

Se a sua API precisa de saltos arbitrários (por exemplo, permitir pular direto para a página 1000) e não se importa com custo, OFFSET pode ser aceitável. Caso contrário, prefira cursor-based.

O que é um cursor opaco

Cursor opaco é uma string que o servidor gera representando o estado (por exemplo, uma tupla de colunas: createdAt + id). O cliente não precisa entender seu conteúdo — apenas devolver para obter a página seguinte. Normalmente é codificado em base64, às vezes com HMAC para evitar manipulação.

Tip: mantenha o cursor pequeno e estável. Evite incluir dados que mudem frequentemente (ex.: JSON de campos mutáveis).

2) Abordagem ingênua vs abordagem otimizada

A abordagem ingênua é usar OFFSET e LIMIT no EF Core: Skip(page * pageSize).Take(pageSize). Isso funciona corretamente em termos de dados, mas escala mal. Para páginas altas, o banco precisa pular N linhas e ainda executar ordenação.

Além disso, em sistemas com escrita concorrente, OFFSET+LIMIT pode produzir itens duplicados ou pular itens se registros forem inseridos ou removidos entre requisições. Isso cria uma experiência ruim em feeds e histórico.

Problemas específicos do OFFSET

Os problemas práticos que já vi em produção incluem: queries que levam segundos para páginas altas, índices não usados por causa de ordenações compostas mal definidas, e bugs de UI causados por itens repetidos enquanto o usuário paginava rapidamente.

Vantagens do keyset/cursor

Keyset resolve os pontos acima porque filtra por valores de chave em vez de pular linhas: WHERE (createdAt < lastCreatedAt) OR (createdAt == lastCreatedAt AND id < lastId) ORDER BY createdAt DESC, id DESC LIMIT pageSize. Isso usa índice em (createdAt, id) de forma eficiente.

Outro ganho é previsibilidade: latência estável independentemente da página, desde que exista um índice adequado.

3) Implementação com código real

Aqui vamos implementar um endpoint minimal API que retorna um feed com cursor-based pagination. Pressupostos: ASP.NET Core 7+, EF Core 7+, entidade Post com CreatedAt (DateTime) e Id (int/long). Usaremos cursor opaco base64 contendo createdAt ticks e id, e mostrarei como decodificar de forma segura.

Modelos e DbContext

Primeiro, as entidades e o DbContext. O código cria a entidade Post e configura índice composto. Isso garante que a query keyset use o índice.

// DbContext e entidade Post
public class AppDbContext : DbContext
{
    public DbSet<Post> Posts { get; set; }

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

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Post>(b =>
        {
            b.HasKey(p => p.Id);
            b.HasIndex(p => new { p.CreatedAt, p.Id }); // índice composto para keyset
        });
    }
}

public class Post
{
    public long Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public string Title { get; set; }
    public string Body { get; set; }
}

Depois de aplicar migrations, a tabela terá índice (CreatedAt, Id). O próximo passo é a função de geração e parsing do cursor.

Gerando e decodificando o cursor

O cursor aqui concatena ticks do DateTime (8 bytes) e o Id (8 bytes) e codifica em base64. É simples, não é criptografia — se quiser, adicione HMAC com uma chave secreta.

// Helpers para cursor
public static class CursorHelper
{
    public static string EncodeCursor(DateTime createdAt, long id)
    {
        var buffer = new byte[16];
        BitConverter.GetBytes(createdAt.ToUniversalTime().Ticks).CopyTo(buffer, 0);
        BitConverter.GetBytes(id).CopyTo(buffer, 8);
        return Convert.ToBase64String(buffer);
    }

    public static (DateTime createdAt, long id) DecodeCursor(string cursor)
    {
        var buffer = Convert.FromBase64String(cursor);
        var ticks = BitConverter.ToInt64(buffer, 0);
        var id = BitConverter.ToInt64(buffer, 8);
        return (new DateTime(ticks, DateTimeKind.Utc), id);
    }
}

Ao usar esse formato, evitamos ambiguidade de timezone e preservamos ordenação exata por ticks.

Endpoint Minimal API: GET /posts

Implementamos o endpoint que aceita query params: limit (int) e cursor (string opcional). A query aplica filtro keyset quando cursor presente. Atenção ao ORDER BY e aos comparadores quando a ordenação é descendente.

// Minimal API - endpoint para paginação keyset
app.MapGet("/posts", async (HttpRequest req, AppDbContext db) =>
{
    var q = req.Query;
    int limit = Math.Clamp(int.TryParse(q["limit"], out var l) ? l : 20, 1, 100);
    string cursor = q["cursor"];

    IQueryable<Post> baseQuery = db.Posts;
    // ordenamos por CreatedAt DESC, Id DESC (feed mais recente primeiro)
    baseQuery = baseQuery.OrderByDescending(p => p.CreatedAt).ThenByDescending(p => p.Id);

    if (!string.IsNullOrEmpty(cursor))
    {
        var (createdAtCursor, idCursor) = CursorHelper.DecodeCursor(cursor);
        // keyset: pegar registros com (CreatedAt < cursor.createdAt) OR (CreatedAt == cursor.createdAt AND Id < cursor.id)
        baseQuery = baseQuery.Where(p => p.CreatedAt < createdAtCursor || (p.CreatedAt == createdAtCursor && p.Id < idCursor));
    }

    var items = await baseQuery.Take(limit + 1).ToListAsync(); // pegamos 1 a mais para saber se há next

    string nextCursor = null;
    if (items.Count == limit + 1)
    {
        var last = items[^1];
        nextCursor = CursorHelper.EncodeCursor(last.CreatedAt, last.Id);
        items.RemoveAt(items.Count - 1);
    }

    return Results.Ok(new { items, nextCursor });
});

Contexto: esse código é funcional em um projeto minimal API. A query busca limit+1 itens para determinar se existe próxima página e remove o item extra antes de retornar. O cursor gerado aponta para o último item da página retornada.

4) Testes e validação

Testar paginação cursor-based tem duas frentes: correctness (não perder/duplicar itens) e performance (consultas usam índice). Vamos cobrir ambos: testes de integração que validam ordenação e continuidade, e análise de plano de execução para garantir uso de índices.

Teste de integração com WebApplicationFactory

Exemplo de teste que publica 50 posts e navega por páginas, verificando que nenhum item é duplicado e que todos são retornados.

// Teste de integração (xUnit) - navegação completa
[Fact]
public async Task PaginacaoCursor_DeveRetornarTodosItensSemDuplicatas()
{
    using var factory = new WebApplicationFactory<Program>();
    var client = factory.CreateClient();

    // pré-condição: já existem 50 posts (seed)
    var seen = new HashSet<long>();
    string cursor = null;
    int total = 0;

    do
    {
        var url = $"/posts?limit=10" + (cursor != null ? $"&cursor={Uri.EscapeDataString(cursor)}" : "");
        var res = await client.GetFromJsonAsync<PagedResponse>(url);
        foreach (var p in res.items) seen.Add(p.Id);
        total += res.items.Length;
        cursor = res.nextCursor;
    } while (!string.IsNullOrEmpty(cursor));

    Assert.Equal(50, total);
    Assert.Equal(50, seen.Count);
}

record PagedResponse(Post[] items, string nextCursor);

Contexto: o teste usa a API real em memória e valida que, ao seguir os cursors, todos os itens são obtidos sem duplicatas. Em CI, prefira banco in-memory ou containerized SQL para performance previsível.

Validando uso de índice

Para garantir performance, inspecione o plano de execução no banco (EXPLAIN ANALYZE) e verifique se o índice composto (CreatedAt, Id) é usado. Em SQL Server use SET STATISTICS PROFILE ON, em PostgreSQL use EXPLAIN ANALYZE.

Regra prática: a cláusula WHERE deve filtrar pela primeira coluna do índice (CreatedAt) com um operador compatível (<, >), permitindo seeks em vez de scans.

5) Considerações de produção

Ao levar essa implementação para produção, há detalhes operacionais e de segurança a considerar: assinatura de cursor, paginação reversa, ordenações customizadas, e tratamento de dados mutáveis.

Assinando o cursor e segurança

Se você não quer que clientes alterem cursors (ex.: aumentar id para pular itens), assine o cursor com HMAC. Adicione a chave secreta no appsettings e gere HMAC-SHA256 sobre o buffer antes de base64. No decode, valide a assinatura. Isso impede manipulação simples.

// Exemplo mínimo de HMAC para assinar cursor
public static string EncodeCursorSigned(DateTime createdAt, long id, byte[] key)
{
    var buffer = new byte[16];
    BitConverter.GetBytes(createdAt.ToUniversalTime().Ticks).CopyTo(buffer, 0);
    BitConverter.GetBytes(id).CopyTo(buffer, 8);
    using var hmac = new HMACSHA256(key);
    var mac = hmac.ComputeHash(buffer);
    var combined = new byte[buffer.Length + 8];
    Buffer.BlockCopy(buffer, 0, combined, 0, buffer.Length);
    Buffer.BlockCopy(mac, 0, combined, buffer.Length, 8); // truncar MAC
    return Convert.ToBase64String(combined);
}

Contexto: truncar MAC é comum para economizar espaço; ajuste conforme necessidade e política de segurança.

Paginação reversa e ordenações distintas

Para permitir navegação anterior (prev), você precisa gerar cursor para direção oposta e aplicar filtros simétricos. Alternativamente, implemente prev como buscas pela primeira página a partir do cursor atual invertendo a ordenação.

Se a ordenação não for por CreatedAt (ex.: ordenação por relevância), você precisa de uma coluna estável e indexável para keyset. Evite usar colunas calculadas ou não-determinísticas.

Erros comuns e armadilhas

Segue uma lista prática de problemas frequentes:

  1. Usar Only CreatedAt sem tie-breaker: quando múltiplos registros têm o mesmo CreatedAt, você pode pular/duplicar itens. Sempre inclua uma coluna tie-breaker estável (Id).
  2. Ordenação inconsistente entre paginação: ORDER BY deve ser estritamente a mesma em todas as requisições (mesma direção) ou o cursor perde validade.
  3. Não ter índice composto: sem índice (CreatedAt, Id) o banco fará full scan ou sort large, quebrando a promessa de performance.
  4. Cursor legível: não exponha campos sensíveis no cursor; use encoding/assinatura.
  5. Negligenciar timezone: normalize todos DateTime para UTC ao codificar/decodificar.
Dica: documente o comportamento do cursor na API (opaco, não reescritura) e exponha limite máximo por request para evitar pesquisas caras.

Conclusão prática

Cursor-based pagination com EF Core e Minimal APIs é uma solução robusta para listar grandes conjuntos de dados com latência previsível. A implementação exige: índice composto adequado, cursor opaco que represente a posição (ex.: CreatedAt + Id), e queries LINQ que traduzam corretamente o keyset filter.

Ao implementar, siga estas práticas: inclua tie-breaker (Id), normalize DateTime para UTC, gere cursor com formatação binária e base64, e considere HMAC para integridade do cursor. Teste navegação completa em integração e valide planos de execução para garantir uso de índices.

Com esses passos você terá uma API que escala melhor que OFFSET e fornece experiência de navegação estável para clientes (Blazor, SPA, mobile), reduzindo latência e carga no banco.

Checklist rápido antes de ir à produção:

  • Índice composto criado (createdAt, id)
  • Cursor opaco com UTC ticks + id
  • Teste de integração que valida continuidade sem duplicatas
  • Planos de execução validados para index seeks
  • HMAC se necessário

Implemente, meça e ajuste o schema/índices conforme padrão de acesso real. A diferença entre OFFSET e keyset aparece rápido em tabelas grandes; a troca quase sempre vale a pena para workloads de leitura intensiva.

Assuntos: aspnet-core, ef-core, pagination, minimal-apis, linq

Leia também