Paginação por offset começa simples e termina cara: quando migrar para cursor

Paginação por offset é sedutora porque cabe numa frase: pegue 20 registros e pule os 40 anteriores. A interface exibe páginas numeradas, a API recebe page=3 e o banco executa LIMIT 20 OFFSET 40. Para muita aplicação, isso é suficiente por bastante tempo.

O atrito aparece quando a lista cresce, muda enquanto o usuário navega ou vira um feed infinito. Nesse momento, o número da página deixa de representar uma posição estável, e o banco começa a trabalhar para descartar cada vez mais linhas.

O banco ainda percorre o que você mandou pular

A documentação do PostgreSQL é direta: as linhas ignoradas por OFFSET ainda precisam ser computadas no servidor. Portanto, um offset grande pode ser ineficiente. Um índice adequado ajuda a localizar e ordenar os dados, mas não transforma a página 50 mil na página 2. O banco continua precisando avançar até o ponto solicitado.

Há outro requisito fácil de esquecer. A ordenação precisa ser única e previsível. Ordenar apenas por created_at DESC não basta se vários registros compartilham o mesmo instante. Acrescentar um desempate estável, normalmente o identificador, evita que o plano do banco devolva subconjuntos inconsistentes.

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

Quando a lista muda no meio da navegação

Imagine que o visitante carregou os primeiros 20 itens. Antes de pedir a página seguinte, cinco novos posts entraram no topo. O offset 20 agora começa cinco posições antes em relação ao conjunto que ele viu. Alguns itens se repetem. Exclusões fazem o movimento contrário e podem produzir lacunas.

Isso não significa que offset esteja quebrado. Ele responde corretamente à fotografia atual da tabela. O problema é que a interface costuma prometer continuidade entre fotografias diferentes.

Cursor descreve uma fronteira, não um número

Na paginação por cursor, o cliente pede os próximos itens depois do último que recebeu. Para a ordenação anterior, a fronteira é o par (created_at, id). A consulta procura valores menores que esse par e usa o índice para continuar a leitura.

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < (:created_at, :id)
ORDER BY created_at DESC, id DESC
LIMIT 21;

Buscar 21 registros para uma página de 20 é uma forma barata de calcular hasNextPage. O cursor enviado ao cliente pode ser uma string opaca contendo os valores da fronteira e, se necessário, uma assinatura. Opaco é importante: a API pode mudar sua codificação sem convidar consumidores a montar cursores manualmente.

A migração vale quando o produto pede continuidade

  • Feeds e timelines com inserções frequentes são fortes candidatos.
  • Listas muito profundas, nas quais offsets altos já aparecem nos planos e nas métricas, também.
  • Rolagem infinita combina naturalmente com “carregar depois deste item”.
  • APIs públicas se beneficiam da estabilidade, mas precisam documentar direção, filtros e validade do cursor.

Offset continua melhor quando o usuário realmente precisa saltar para a página 37, quando o conjunto é pequeno ou quando relatórios trabalham sobre uma fotografia imutável. Cursor não oferece acesso aleatório de graça. Voltar páginas exige cursores anteriores ou uma estratégia bidirecional, e mudanças nos filtros invalidam a fronteira.

A especificação de Connections do GraphQL formaliza uma interface conhecida: first, after, cursores por aresta e pageInfo. Mesmo numa API REST, esse vocabulário é uma boa referência. A implementação concreta, porém, deve nascer da ordenação e dos índices do banco, não de uma cópia cosmética do formato.

O sinal para migrar não é “cursor é moderno”. É quando profundidade, mutação ou continuidade tornam o offset caro ou surpreendente. Antes disso, a solução simples ainda merece ficar.

Fontes