Pagination com cursor vs offset: qual escolher e quando usar
1. Fundamentos da Paginação em APIs REST
Paginação é o mecanismo que divide grandes conjuntos de resultados em partes menores (páginas) para entrega eficiente via API. Sem paginação, uma requisição que retorna milhares de registros pode causar timeouts, estourar a memória do servidor e degradar a experiência do usuário com longos tempos de carregamento.
Dois paradigmas dominam as implementações modernas: paginação por offset (baseada em páginas numeradas) e paginação por cursor (baseada em continuidade). Cada um resolve o mesmo problema fundamental — fragmentar dados — mas com abordagens, vantagens e trade-offs radicalmente diferentes.
2. Paginação por Offset: Simplicidade com Armadilhas
A paginação por offset é a mais intuitiva. O cliente especifica um número de página e um limite de itens por página:
GET /api/items?page=2&limit=20
No backend, a lógica SQL equivalente é:
SELECT * FROM items ORDER BY id LIMIT 20 OFFSET 40;
Vantagens:
- Implementação direta e compreensível
- Suporte nativo em ORMs (ActiveRecord, Prisma, Sequelize)
- Navegação fácil para páginas específicas (usuário pode pular para página 50)
Desvantagens críticas:
-
Inconsistência com dados dinâmicos: Se um registro é inserido ou removido entre requisições, o usuário pode ver itens repetidos ou pular itens. Exemplo: ao paginar uma lista de posts, se um novo post é inserido no topo, toda a sequência de páginas se desloca.
-
Performance degradante em OFFSET alto: O banco de dados precisa escanear e descartar todas as linhas anteriores ao offset. Para
OFFSET 100000, o SQL executa:
SELECT * FROM items ORDER BY id LIMIT 20 OFFSET 100000;
Isso força o banco a ler 100.020 linhas e descartar 100.000. Em tabelas com milhões de registros, essa operação se torna proibitiva.
- Falta de estabilidade: Em listas ordenadas por data de criação, inserções simultâneas alteram a posição relativa dos itens.
3. Paginação por Cursor: Precisão e Consistência
A paginação por cursor usa um marcador (cursor) que aponta para uma posição específica no conjunto de dados:
GET /api/items?cursor=eyJpZCI6MTIzfQ==&limit=20
O backend decodifica o cursor e aplica um filtro baseado em chave:
SELECT * FROM items WHERE id > 123 ORDER BY id LIMIT 20;
Vantagens:
-
Performance O(log n): Usando índices, o banco encontra diretamente a posição do cursor sem escanear linhas descartadas. Para
WHERE id > 123, o índice B-tree localiza o valor em tempo logarítmico. -
Resultados estáveis: Mesmo com inserções ou remoções simultâneas, o cursor mantém a posição correta. O usuário não vê itens repetidos nem pula registros.
-
Consistência em dados dinâmicos: Ideal para feeds onde novos itens chegam constantemente.
Desvantagens:
-
Complexidade de implementação: Requer codificação/decodificação de cursores, tratamento de ordenação reversa e índices compostos.
-
Dificuldade em navegação "pular para página X": Não é possível calcular o cursor de uma página arbitrária sem percorrer todo o conjunto.
-
Exposição potencial de chaves internas: Se o cursor contém IDs sequenciais, o cliente pode inferir o volume total de dados.
4. Quando Usar Offset (e quando evitar)
Cenários ideais:
- Dados estáticos ou históricos (logs antigos, catálogos imutáveis)
- Interfaces com numeração de páginas (ex: resultados de busca do Google)
- Conjuntos pequenos (menos de 10.000 registros) com baixa taxa de alteração
- APIs internas onde performance não é crítica
Armadilhas a evitar:
- Feeds em tempo real (redes sociais, notificações)
- Listas com alta taxa de inserção/remoção
- Tabelas com milhões de registros
- Qualquer cenário onde consistência de dados é prioritária
Exemplo de implementação offset (Node.js com Prisma):
// Controller
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
const items = await prisma.item.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' }
});
const total = await prisma.item.count();
res.json({
data: items,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
});
5. Quando Usar Cursor (e como implementar corretamente)
Cenários ideais:
- Feeds sociais (Twitter, Instagram)
- Timelines e listas infinitas (scroll infinito)
- Dados em tempo real (logs de sistema, transações financeiras)
- Qualquer API com milhões de registros e alta concorrência
Implementação prática:
// Backend - geração do cursor
function encodeCursor(item) {
const payload = { id: item.id, createdAt: item.createdAt };
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
// Controller
const limit = parseInt(req.query.limit) || 20;
let cursor = null;
if (req.query.cursor) {
const decoded = JSON.parse(
Buffer.from(req.query.cursor, 'base64url').toString()
);
cursor = decoded;
}
const items = await prisma.item.findMany({
take: limit + 1, // Pega um extra para saber se há próxima página
where: cursor ? {
OR: [
{ createdAt: { lt: cursor.createdAt } },
{ createdAt: cursor.createdAt, id: { lt: cursor.id } }
]
} : undefined,
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
]
});
const hasNextPage = items.length > limit;
const resultItems = hasNextPage ? items.slice(0, limit) : items;
const nextCursor = hasNextPage ? encodeCursor(resultItems[resultItems.length - 1]) : null;
res.json({
data: resultItems,
nextCursor,
hasNextPage
});
Cuidados essenciais:
- Use chave composta
(created_at, id)para garantir unicidade - Codifique cursores em base64url (seguro para URLs)
- Crie índices compostos no banco:
CREATE INDEX idx_created_at_id ON items (created_at DESC, id DESC) - Evite expor IDs sequenciais sem tratamento
6. Estratégias Híbridas e Considerações Avançadas
Combinação offset + cursor:
Para interfaces que precisam de navegação geral (páginas 1, 2, 3...) e scroll contínuo, pode-se oferecer ambos:
GET /api/items?page=2&limit=20 # Navegação tradicional
GET /api/items?cursor=...&limit=20 # Scroll contínuo
Paginação baseada em chaves parciais:
Uma variação mais simples do cursor usa apenas o último ID:
GET /api/items?since_id=123&limit=20
Menos robusta que o cursor completo, mas adequada para casos simples com ordenação por ID.
Tratamento de bordas:
- Último item repetido: Ao buscar
limit+1itens para determinarhasNextPage, garanta que o último item da resposta anterior não seja reexibido - Race conditions: Em alta concorrência, registros podem ser inseridos entre a requisição do cursor e a resposta. Use transações ou bloqueios otimistas
7. Tomada de Decisão: Checklist para o Desenvolvedor
Perguntas-chave:
- Os dados mudam com frequência? (Sim → cursor)
- O dataset tem mais de 100.000 registros? (Sim → cursor)
- O usuário precisa pular para páginas arbitrárias? (Sim → offset ou híbrido)
- A consistência dos resultados é crítica? (Sim → cursor)
- A equipe tem tempo para implementar complexidade extra? (Não → offset)
Tabela comparativa:
| Critério | Offset | Cursor |
|---|---|---|
| Performance em grandes datasets | Ruim (O(n)) | Excelente (O(log n)) |
| Consistência com dados dinâmicos | Baixa | Alta |
| Complexidade de implementação | Baixa | Média-Alta |
| Navegação para páginas específicas | Sim | Não |
| Manutenção a longo prazo | Simples | Requer índices |
Recomendação prática:
Comece com cursor para APIs novas. Use offset apenas com justificativa clara (dados estáticos, necessidade de numeração de páginas, time pequeno). Para a maioria dos casos modernos — especialmente em Temas — Lista Final (1200 temas) — o cursor oferece a melhor combinação de performance, consistência e escalabilidade.
Referências
- Cursor Pagination Explained — Documentação oficial do Prisma sobre implementação de paginação por cursor com exemplos práticos
- Offset vs Cursor Pagination: Which One to Choose? — Artigo técnico detalhando as diferenças entre os dois métodos com benchmarks de performance
- REST API Pagination Best Practices — Guia prático de design de APIs RESTful, incluindo seção sobre paginação
- Using Cursor-based Pagination in PostgreSQL — Tutorial avançado sobre implementação de cursor pagination no PostgreSQL com foco em índices
- Twitter API Pagination Guide — Documentação oficial da API do Twitter explicando como implementam paginação por cursor em larga escala
- GraphQL Connections Specification — Especificação do Relay para conexões GraphQL, que popularizou o padrão de cursor-based pagination
- Handling Pagination in High-Throughput Systems — Artigo sobre desafios de paginação em sistemas distribuídos com alta concorrência