Paginação: offset vs cursor em listas grandes

Entenda por que a paginação com OFFSET fica lenta e inconsistente em tabelas grandes, como a paginação por cursor resolve isso com índices, e quando cada abordagem faz sentido.


Toda listagem grande precisa de paginação, e a primeira implementação que quase todo mundo escreve é a mesma: LIMIT e OFFSET. Funciona perfeitamente nos testes, com 200 registros. O problema aparece meses depois, quando a tabela tem milhões de linhas e a página 5000 leva segundos pra carregar.

Paginação por offset

A ideia é pular N registros e pegar os próximos:

-- página 1
SELECT * FROM pedidos ORDER BY id DESC LIMIT 20 OFFSET 0;
 
-- página 3
SELECT * FROM pedidos ORDER BY id DESC LIMIT 20 OFFSET 40;

É simples, permite pular direto pra qualquer página e dá pra mostrar "página 3 de 50". Por isso é tão popular. Mas tem dois problemas sérios.

Problema 1: fica mais lento a cada página

O banco não consegue "pular" linhas de graça. Pra entregar OFFSET 100000, ele precisa percorrer as 100 mil primeiras linhas e descartá-las, só pra então devolver as 20 seguintes. O custo cresce com o tamanho do offset: a página 1 é instantânea, a página 5000 é lenta, mesmo com índice.

Problema 2: resultados duplicados ou perdidos

Imagina que o usuário está na página 1 e, enquanto lê, alguém cria um pedido novo. Quando ele pede a página 2, tudo deslocou uma posição: o último item da página 1 agora aparece como o primeiro da página 2, repetido. Se um registro for removido, o oposto acontece e um item some sem o usuário ver. Em listas que mudam o tempo todo, o offset entrega uma visão instável.

Paginação por cursor

Em vez de dizer "pule 40 linhas", você diz "me dê os 20 itens depois deste aqui". O cursor é o valor do último item que o cliente recebeu:

-- primeira página
SELECT * FROM pedidos ORDER BY id DESC LIMIT 20;
 
-- próxima página, usando o id do último item recebido (ex: 9830)
SELECT * FROM pedidos WHERE id < 9830 ORDER BY id DESC LIMIT 20;

Com um índice na coluna id (algo que já comentei no post sobre índices), o banco vai direto ao ponto 9830 e lê só 20 linhas. O custo é o mesmo na página 1 e na página 5000. E como o ponto de partida é um registro específico, itens novos ou removidos não deslocam a lista: sem duplicados, sem buracos.

Na API

O formato de resposta costuma devolver o cursor pronto pro cliente usar:

app.get("/pedidos", async (req, res) => {
  const limite = Math.min(Number(req.query.limite) || 20, 100);
  const cursor = req.query.cursor ? Number(req.query.cursor) : null;
 
  const pedidos = await db.pedido.findMany({
    where: cursor ? { id: { lt: cursor } } : undefined,
    orderBy: { id: "desc" },
    take: limite + 1, // pede um a mais pra saber se há próxima página
  });
 
  const temMais = pedidos.length > limite;
  const itens = temMais ? pedidos.slice(0, limite) : pedidos;
 
  res.json({
    itens,
    proximoCursor: temMais ? itens[itens.length - 1].id : null,
  });
});

Dois detalhes úteis: pedir limite + 1 registros revela se existe próxima página sem fazer um COUNT caro, e limitar o tamanho máximo (Math.min(..., 100)) impede que alguém peça um milhão de linhas de uma vez.

O cuidado com a ordenação

O cursor só funciona se a ordenação for determinística. Ordenar por uma coluna que se repete, como criado_em com vários registros no mesmo segundo, causa saltos e repetições. A solução é usar um critério de desempate, normalmente o id:

SELECT * FROM pedidos
WHERE (criado_em, id) < ('2026-11-09 10:00:00', 9830)
ORDER BY criado_em DESC, id DESC
LIMIT 20;

Também é comum codificar o cursor em base64 antes de devolver, tratando-o como um valor opaco. Assim o cliente não depende do formato interno e você pode mudá-lo depois.

O que se perde com cursor

Não existe almoço grátis. Com cursor você não consegue:

  • Pular direto pra "página 47", só avançar (e voltar, se guardar cursores anteriores).
  • Mostrar "página 3 de 50" sem uma contagem à parte.

Quando usar cada um

  • Offset: painéis administrativos e tabelas pequenas ou médias, onde o usuário quer pular entre páginas numeradas e a quantidade de dados é limitada.
  • Cursor: feeds, timelines, listas infinitas com "carregar mais", APIs públicas e qualquer tabela que cresce sem parar.

Fechando

Offset é simples, mas cobra um preço que cresce com os dados: lentidão nas páginas finais e listas que se deslocam durante a navegação. Cursor troca a navegação livre por desempenho constante e resultados estáveis. Se a tabela só vai crescer, desenhe a paginação por cursor desde o começo, porque migrar uma API pública depois que clientes dependem do formato antigo é bem mais trabalhoso.

Direto na sua
caixa de entrada.

Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.