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:
- 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).
- 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.
- Não ter índice composto: sem índice (CreatedAt, Id) o banco fará full scan ou sort large, quebrando a promessa de performance.
- Cursor legível: não exponha campos sensíveis no cursor; use encoding/assinatura.
- 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
SignalR com Blazor: criar chat em tempo real com reconexão e fallback
Guia prático em C# para criar um chat com SignalR e Blazor WASM: passo a passo, código real, reconexão automática e fallback para LongPolling quando…
Soft delete no EF Core: filtros globais, auditoria e restauração
Soft delete com EF Core evita perda de dados, permite auditoria e restauração. Aprenda o jeito errado (filtros manuais) e o jeito certo (global query filters…
Tratar concorrência otimista e retries com EF Core e Polly em ASP.NET Core
Combina rowversion, tratamento de DbUpdateConcurrencyException e políticas Polly para retries seguros. Estratégia prática: recriar DbContext, mesclar ou…
EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote
Guia prático para aplicar upserts e operações em lote com EF Core: quando usar ExecuteUpdate/ExecuteDelete, MERGE/ON CONFLICT, bibliotecas de bulk e patterns…
Minimal APIs + SSE: enviar progresso de jobs para clientes Blazor passo a passo
Guia passo a passo para usar Minimal APIs + SSE e enviar progresso de jobs para clientes Blazor. Do setup ao código funcional: servidor com endpoint SSE…
Multitenancy em ASP.NET Core com EF Core: discriminator, schema e database
Comparação prática entre discriminator, schema e database para multitenancy em ASP.NET Core com EF Core. Implementação de TenantProvider, DbContext dinâmico…