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:

  1. 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.

  2. 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.

  1. 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:

  1. 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.

  2. 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.

  3. Consistência em dados dinâmicos: Ideal para feeds onde novos itens chegam constantemente.

Desvantagens:

  1. Complexidade de implementação: Requer codificação/decodificação de cursores, tratamento de ordenação reversa e índices compostos.

  2. 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.

  3. 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+1 itens para determinar hasNextPage, 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:

  1. Os dados mudam com frequência? (Sim → cursor)
  2. O dataset tem mais de 100.000 registros? (Sim → cursor)
  3. O usuário precisa pular para páginas arbitrárias? (Sim → offset ou híbrido)
  4. A consistência dos resultados é crítica? (Sim → cursor)
  5. 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