A paginação por cursor mantém listas estáveis quando os dados mudam. Entenda por que paginação por offset falha com inserções e deleções e como implementar cursores limpos.

Você abre um feed, rola um pouco, e tudo parece normal até que não parece. Você vê o mesmo item duas vezes. Algo que você jura que estava lá sumiu. Uma linha que você ia tocar desloca-se para baixo e você acaba na página de detalhe errada.
Esses são bugs visíveis para o usuário, mesmo que as respostas da sua API pareçam “corretas” isoladamente. Os sintomas comuns são fáceis de identificar:
Isso piora no mobile. Pessoas pausam, trocam de app, perdem conectividade e continuam depois. Nesse intervalo, novos itens chegam, antigos são deletados e alguns são editados. Se seu app continua pedindo “página 3” usando um offset, os limites de página podem mudar enquanto o usuário está no meio do scroll. O resultado é um feed que parece instável e pouco confiável.
O objetivo é simples: uma vez que o usuário começa a rolar para frente, a lista deve se comportar como um snapshot. Novos itens podem existir, mas não devem reordenar o que o usuário já está paginando. O usuário deve obter uma sequência suave e previsível.
Nenhum método de paginação é perfeito. Sistemas reais têm escritas concorrentes, edições e múltiplas opções de ordenação. Mas a paginação por cursor costuma ser mais segura que a por offset porque ela pagina a partir de uma posição numa ordem estável, em vez de a partir de uma contagem de linhas que se move.
Paginação por offset é o jeito “pule N, pegue M” para navegar numa lista. Você diz à API quantos itens pular (offset) e quantos retornar (limit). Com limit=20, você recebe 20 itens por página.
Conceitualmente:
GET /items?limit=20&offset=0 (primeira página)GET /items?limit=20&offset=20 (segunda página)GET /items?limit=20&offset=40 (terceira página)A resposta normalmente inclui os itens mais informação suficiente para pedir a próxima página.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
É popular porque se encaixa bem em tabelas, listas de admin, resultados de busca e feeds simples. Também é fácil de implementar com SQL usando LIMIT e OFFSET.
O problema é a suposição oculta: o conjunto de dados permanece parado enquanto o usuário navega. Em apps reais, novas linhas são inseridas, linhas são deletadas e chaves de ordenação mudam. É aí que os “bugs misteriosos” começam.
A paginação por offset assume que a lista fica estática entre requisições. Mas listas reais se movem. Quando a lista se desloca, um offset como “pular 20” deixa de apontar para os mesmos itens.
Imagine um feed ordenado por created_at desc (mais novo primeiro), tamanho de página 3.
Você carrega a página 1 com offset=0, limit=3 e recebe [A, B, C].
Agora um novo item X é criado e aparece no topo. A lista fica [X, A, B, C, D, E, F, ...]. Você carrega a página 2 com offset=3, limit=3. O servidor pula [X, A, B] e retorna [C, D, E].
Você acabou vendo C de novo (uma duplicata), e mais tarde vai perder um item porque tudo deslocou para baixo.
Deletes causam a falha oposta. Comece com [A, B, C, D, E, F, ...]. Você carrega a página 1 e vê [A, B, C]. Antes da página 2, B é deletado, então a lista vira [A, C, D, E, F, ...]. A página 2 com offset=3 pula [A, C, D] e retorna [E, F, G]. D vira uma lacuna que você nunca busca.
Em feeds com mais novo primeiro, inserções acontecem no topo, que é exatamente o que desloca todos os offsets seguintes.
Uma “lista estável” é o que os usuários esperam: conforme rolam para frente, os itens não pulam, não se repetem nem desaparecem sem motivo claro. É menos sobre congelar o tempo e mais sobre tornar a paginação previsível.
Duas ideias frequentemente se misturam:
created_at com um tie-breaker como id) de modo que duas requisições com as mesmas entradas retornem a mesma ordem.Refresh e scroll-forward são ações diferentes. Refresh significa “mostre o que há de novo agora”, então o topo pode mudar. Scroll-forward significa “continue de onde eu estava”, então você não deveria ver repetições ou lacunas inesperadas causadas por limites de página que se deslocaram.
Uma regra simples que evita a maioria dos bugs de paginação: scrolling forward nunca deve mostrar repetições.
A paginação por cursor navega por uma lista usando um marcador em vez de um número de página. Em vez de “me dê a página 3”, o cliente diz “continue daqui”.
O contrato é direto:
Isso tolera inserções e deleções melhor porque o cursor ancora a uma posição na ordem ordenada, não a uma contagem de linhas que se move.
O requisito inegociável é uma ordem determinística. Você precisa de uma regra de ordenação estável e um tie-breaker consistente; caso contrário o cursor não é um marcador confiável.
Comece escolhendo uma ordem que combine com a forma como as pessoas leem a lista. Feeds, mensagens e logs de atividade costumam ser mais novos primeiro. Históricos como faturas e logs de auditoria são frequentemente mais fáceis de ler do mais antigo para o mais novo.
Um cursor deve identificar unicamente uma posição nessa ordem. Se dois itens podem compartilhar o mesmo valor de cursor, você eventualmente terá duplicatas ou lacunas.
Escolhas comuns e o que observar:
created_at sozinho: simples, mas inseguro se muitas linhas compartilham o mesmo timestamp.id sozinho: seguro se IDs forem monotônicos, mas pode não corresponder à ordem desejada pelo produto.created_at + id: geralmente a melhor combinação (timestamp para a ordem, id como tie-breaker).updated_at como ordenação primária: arriscado para scroll infinito porque edições podem mover itens entre páginas.Se você oferecer múltiplas opções de ordenação, trate cada modo de ordenação como uma lista diferente com suas próprias regras de cursor. Um cursor só faz sentido para uma ordenação exata.
Você pode manter a superfície da API pequena: duas entradas, duas saídas.
Envie um limit (quantos itens quer) e um cursor opcional (onde continuar). Se o cursor estiver ausente, o servidor retorna a primeira página.
Exemplo de requisição:
GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Retorne os itens e um next_cursor. Se não houver próxima página, retorne next_cursor: null. Clientes devem tratar o cursor como um token, não algo para editar.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Lógica do lado do servidor em palavras simples: ordene numa ordem estável, filtre usando o cursor e então aplique o limit.
Se você ordenar mais novo primeiro por (created_at DESC, id DESC), decodifique o cursor em (created_at, id), então busque linhas onde (created_at, id) é estritamente menor que o par do cursor, aplique a mesma ordem e pegue limit linhas.
Você pode codificar o cursor como um blob JSON base64 (fácil) ou como um token assinado/criptografado (mais trabalho). Opaco é mais seguro porque permite mudar a implementação interna depois sem quebrar clientes.
Também defina padrões sensatos: um padrão móvel razoável (geralmente 20–30), um padrão web (geralmente 50) e um máximo rígido no servidor para que um cliente com bug não peça 10.000 linhas.
Um feed estável é, em sua essência, sobre uma promessa: uma vez que o usuário começa a rolar para frente, os itens que ele ainda não viu não devem pular porque outra pessoa criou, deletou ou editou registros.
Com paginação por cursor, inserções são as mais fáceis. Novos registros devem aparecer no refresh, não no meio das páginas já carregadas. Se você ordenar por created_at DESC, id DESC, novos itens naturalmente ficam antes da primeira página, então seu cursor existente continua para itens mais antigos.
Deleções não devem reordenar a lista. Se um item é deletado, ele simplesmente não será retornado quando você fosse buscá-lo. Se você precisar manter o tamanho da página consistente, continue buscando até reunir limit itens visíveis.
Edições são onde equipes acidentalmente reintroduzem bugs. A pergunta chave é: uma edição pode mudar a posição na ordenação?
Comportamento estilo snapshot costuma ser o melhor para listas que se rolam: pagine por uma chave imutável como created_at. Edições podem mudar o conteúdo, mas o item não pula para outra posição.
Comportamento live ordena por algo como edited_at. Isso pode causar saltos (um item antigo é editado e sobe para o topo). Se você escolher isso, trate a lista como constantemente mutável e desenhe o UX em torno do refresh.
Não faça o cursor depender de “encontre esta linha exata”. Codifique a posição em vez disso, por exemplo {created_at, id} do último item retornado. Então a próxima query baseia-se em valores, não na existência da linha:
WHERE (created_at, id) < (:created_at, :id)id) para evitar duplicatasPaginar para frente é a parte fácil. As questões de UX mais complicadas são paginar para trás, refresh e acesso aleatório.
Para paginar para trás, duas abordagens costumam funcionar:
next_cursor para itens mais antigos e prev_cursor para itens mais novos) mantendo uma única ordenação na tela.Saltos aleatórios são mais difíceis com cursores porque “página 20” não tem um significado estável quando a lista muda. Se você realmente precisa de saltos, salte para uma âncora como “em torno deste timestamp” ou “começando deste id”, não para um índice de página.
No mobile, caching importa. Armazene cursores por estado da lista (query + filtros + ordenação) e trate cada aba/visão como sua própria lista. Isso evita comportamento de “trocar de aba e tudo embaralhar”.
A maioria dos problemas com paginação por cursor não vem do banco de dados. Vem de pequenas inconsistências entre requisições que só aparecem sob tráfego real.
Os maiores culpados:
created_at sozinho) de modo que empates produzam duplicatas ou itens faltando.next_cursor que não corresponda ao último item realmente retornado.Se você constrói apps em plataformas como Koder.ai, esses casos de borda aparecem rápido porque clientes web e mobile frequentemente compartilham o mesmo endpoint. Ter um contrato explícito de cursor e uma regra de ordenação determinística mantém ambos os clientes consistentes.
Antes de declarar a paginação “pronta”, verifique o comportamento sob inserções, deleções e tentativas repetidas.
next_cursor é tirado do último registro retornadolimit tem um máximo seguro e um padrão documentadoPara refresh, escolha uma regra clara: ou os usuários puxam para atualizar e obter itens mais novos no topo, ou você verifica periodicamente “há algo mais novo que o meu primeiro item?” e mostra um botão “Novos itens”. Consistência é o que faz a lista parecer estável em vez de assombrada.
Imagine uma caixa de entrada de suporte que agentes usam na web, enquanto um gerente verifica a mesma caixa no mobile. A lista está ordenada por mais novo primeiro. As pessoas esperam uma coisa: quando rolam para frente, os itens não pulam, não se repetem nem desaparecem.
Com paginação por offset, um agente carrega a página 1 (itens 1–20) e depois rola para a página 2 (offset=20). Enquanto ele lê, duas novas mensagens chegam ao topo. Agora offset=20 aponta para outro lugar do que apontava um segundo atrás. O usuário vê duplicatas ou perde mensagens.
Com paginação por cursor, o app pede “os próximos 20 itens depois deste cursor”, onde o cursor é baseado no último item que o usuário realmente viu (comum usar (created_at, id)). Novas mensagens podem chegar o dia todo, mas a próxima página ainda começa logo após a última mensagem que o usuário viu.
Uma maneira simples de testar antes de enviar:
Se você está prototipando rápido, Koder.ai pode ajudar a scaffoldar o endpoint e os fluxos do cliente a partir de um prompt no chat, depois iterar com segurança usando o Planning Mode além de snapshots e rollback quando uma mudança de paginação te surpreender nos testes.
A paginação por offset aponta para “pular N linhas”, então quando novas linhas são inseridas ou linhas antigas são deletadas, a contagem de linhas muda. O mesmo offset pode passar a referir-se a itens diferentes do que referia antes, o que cria duplicatas e lacunas para usuários que estão no meio do scroll.
A paginação por cursor usa um marcador que representa “a posição após o último item que vi”. A próxima requisição continua a partir dessa posição numa ordem determinística, então inserções no topo e deleções no meio não deslocam a fronteira da página como os offsets fazem.
Use uma ordenação determinística com um tie-breaker, tipicamente (created_at, id) na mesma direção. created_at fornece a ordem intuitiva do produto, e id torna cada posição única para que você não repita ou pule itens quando timestamps colidirem.
Ordenar por updated_at pode fazer itens saltarem entre páginas quando são editados, o que quebra a expectativa de “rolar para frente estável”. Se você precisa de uma visão “mais recentemente atualizada”, projete a UI para atualizar e aceitar reordenações em vez de prometer um scroll infinito estável.
Retorne um token opaco em next_cursor e faça o cliente devolvê-lo sem alterações. Uma abordagem simples é codificar o (created_at, id) do último item em um blob JSON base64, mas tratar o cursor como um valor opaco é o ponto importante para permitir mudanças internas no futuro.
Construa a próxima consulta a partir dos valores do cursor, não de “encontre esta linha exata”. Se o último item foi deletado, o (created_at, id) armazenado ainda define uma posição, então você pode continuar com segurança usando uma comparação estrita na mesma ordenação.
Use uma comparação estrita e um tie-breaker único, e sempre gere o cursor a partir do último item que você realmente retornou. A maioria dos bugs de repetição vem de usar <= em vez de <, omitir o tie-breaker ou gerar o next_cursor a partir da linha errada.
Escolha uma regra clara: refresh carrega itens mais novos no topo, enquanto scroll-forward continua para itens mais antigos a partir do cursor existente. Não misture as semânticas de “refresh” no mesmo fluxo de cursor, ou os usuários verão reordenações e acharão a lista não confiável.
Um cursor é válido apenas para uma ordenação e conjunto de filtros exatos. Se o cliente mudar o modo de ordenação, a query de busca ou os filtros, ele deve iniciar uma nova sessão de paginação sem cursor e armazenar cursores separadamente por estado da lista.
A paginação por cursor é excelente para navegação sequencial, mas não para saltos estáveis como “página 20”, pois o dataset pode mudar. Se você precisa pular, salte para uma âncora como “ao redor deste timestamp” ou “começando depois deste id” e então pagine com cursores a partir daí.