DEV Community

Cover image for Paginação por Cursor vs. Paginação por Offset: Qual a Melhor para Sua API?
Lucas
Lucas

Posted on Originally published at apidog.com

Paginação por Cursor vs. Paginação por Offset: Qual a Melhor para Sua API?

Paginação por offset ou cursor: como escolher e testar sua API

Todo endpoint de lista eventualmente enfrenta a mesma pergunta: como dividir 2 milhões de pedidos em páginas que um cliente possa percorrer? A paginação por offset oferece SQL simples e números de página fáceis de entender. A paginação baseada em cursor oferece resultados estáveis e latência consistente em qualquer profundidade, mas elimina o recurso de “ir para a página 47”.

Experimente o Apidog hoje

A maioria das equipes escolhe offset porque esse é o padrão dos tutoriais. Porém, quando a tabela de pedidos chega a alguns milhões de linhas, a página 4.000 começa a expirar e os usuários podem ver o mesmo registro duas vezes durante a rolagem.

Este guia mostra como os dois estilos funcionam, onde o offset falha, por que Stripe e Slack usam cursores e como testar cada abordagem com requisições encadeadas no Apidog. Para uma visão geral, consulte o guia de paginação de API.

Como funciona a paginação por offset

A paginação por offset mapeia diretamente para SQL. O cliente envia um número de página e um tamanho de página; o servidor os traduz em LIMIT e OFFSET:

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Enter fullscreen mode Exit fullscreen mode

Essa consulta retorna a página 3 de uma lista com 25 pedidos por página:

GET /v1/orders?page=3&per_page=25
Enter fullscreen mode Exit fullscreen mode

Uma resposta típica:

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}
Enter fullscreen mode Exit fullscreen mode

O apelo é claro:

  • clientes podem pular para qualquer página;
  • o servidor pode retornar uma contagem total;
  • a implementação é simples.

Para uma tabela administrativa pequena, offset costuma ser a escolha certa. O guia passo a passo para paginação em APIs REST mostra uma implementação completa.

Mas dois problemas estruturais aparecem em produção.

Problema 1: desvio de página

O offset conta linhas a partir do topo do resultado ordenado. Ele não sabe quais registros o cliente já viu. Se linhas forem inseridas ou excluídas entre as requisições, as páginas se deslocam.

Considere um usuário que carrega a página 1 de pedidos, ordenados do mais novo para o mais antigo, com as linhas 1 a 25. Enquanto ele lê, três pedidos novos chegam. Ao solicitar a página 2 (OFFSET 25), as linhas 23, 24 e 25 da primeira resposta foram empurradas para as posições 26 a 28. O usuário as recebe novamente.

A exclusão causa o efeito oposto. Se três linhas forem removidas da página 1, o OFFSET 25 pula três registros que o usuário ainda não viu. O resultado são dados ausentes, sem qualquer erro.

Para um relatório mensal que não é percorrido em tempo real, isso pode ser aceitável. Para um feed de atividades, um endpoint de sincronização ou qualquer script que percorra páginas enquanto novas escritas continuam acontecendo, o desvio gera duplicatas e lacunas.

Problema 2: offsets profundos escaneiam as linhas ignoradas

OFFSET 500000 não teletransporta o banco de dados para a linha 500.001. O banco percorre o índice por meio milhão de entradas, descarta essas entradas e só então retorna as 25 linhas solicitadas.

O custo cresce linearmente com a profundidade: O(n), em que n é o offset.

Em uma tabela Postgres de pedidos com 2 milhões de linhas e um índice em created_at:

  • LIMIT 25 OFFSET 0 lê 25 entradas de índice: alguns milissegundos.
  • LIMIT 25 OFFSET 100000 lê 100.025 entradas e descarta 100.000: dezenas de milissegundos.
  • LIMIT 25 OFFSET 1500000 lê 1,5 milhão de entradas: centenas de milissegundos, mais buffers ocupados e mais CPU por página.

O artigo “no-offset”, de Markus Winand, demonstra esse custo com planos de consulta.

Em produção, é comum encontrar logs de consultas lentas dominados por requisições com offsets altos — frequentemente causadas por um crawler que percorre diligentemente todas as páginas de uma API pública. Um único cliente pode dobrar seu p99.

Como funciona a paginação baseada em cursor

A paginação baseada em cursor, também chamada de paginação por keyset, elimina o contador de linhas. Em vez de dizer “pule 50 linhas”, o cliente diz: “retorne as linhas depois deste registro”.

O cursor identifica a última linha recebida, permitindo que o servidor busque diretamente o próximo lote:

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Enter fullscreen mode Exit fullscreen mode

A comparação usa duas colunas porque created_at sozinho não é único. Dois pedidos podem ser criados no mesmo milissegundo; sem um desempate, registros podem ser ignorados ou repetidos nos limites das páginas.

Adicionar id como desempate torna a ordenação total e a paginação exata. Com um índice composto em (created_at, id), o banco busca diretamente o limite e lê 25 entradas. A primeira página e a página 60.000 têm custo semelhante.

Use cursores opacos

A API não deve expor os valores brutos da ordenação. Codifique a chave em um token opaco, geralmente em Base64:

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Enter fullscreen mode Exit fullscreen mode

Opacidade é uma decisão de design, não apenas ofuscação. Clientes que não conseguem analisar o cursor não podem construir URLs manualmente. Isso permite alterar a chave de ordenação, adicionar uma dica de shard ou trocar o mecanismo de armazenamento sem quebrar o contrato.

O contrato passa a ser: “devolva o cursor que fornecemos.”

A contrapartida é que não existe página 47. O cursor só conhece a posição “depois desta linha”, portanto o cliente avança — e pode retroceder, se você emitir um cursor anterior — uma página por vez. Contagens totais também exigem uma consulta separada.

Para conjuntos de dados muito grandes, consulte como projetar paginação de API para milhões de registros.

Compromissos em um relance

Dimensão Paginação por offset Paginação baseada em cursor
Pular para uma página arbitrária Sim, qualquer número de página Não, apenas caminhada sequencial
Contagem total / páginas Barata de incluir Requer consulta separada
Desempenho em páginas profundas O(n), degrada com a profundidade O(1) por página
Estabilidade sob escritas Pode gerar duplicatas e lacunas Estável, ancorada em uma linha
Custo de construção Trivial Moderado: codificação, desempate e índice
Requisitos de ordenação Qualquer ORDER BY Chave única e indexada
Cache de URLs Fácil, URLs previsíveis Mais difícil, cursores variam
Complexidade do cliente Baixa Baixa, com um envelope bem definido

A paginação por cursor exige uma ordenação determinística. Se o endpoint permitir ordenação por uma coluna mutável e não única, como status, a lógica de keyset se torna rapidamente difícil. O offset tolera uma ordenação imprecisa; os cursores não.

Qual estilo escolher?

Escolha de acordo com a forma como os dados são consumidos.

Tabelas administrativas e dashboards: offset

Use offset para ferramentas internas com alguns milhares de linhas, usuários clicando em números de página e uma contagem visível, como “1.848 resultados”.

O desvio geralmente não importa, a profundidade permanece baixa e “ir para a página” é um recurso útil. Nesse cenário, o menor custo de construção favorece o offset.

Feeds de rolagem infinita: cursor

Ninguém precisa ir para a página 47 de um feed. Os usuários carregam “mais”, as escritas acontecem continuamente e duplicatas são visíveis.

Esse é o caso clássico para cursores.

APIs públicas: cursor

Você não controla os consumidores de uma API pública. Alguém criará um loop para percorrer todas as páginas e, com offset, as páginas profundas se tornarão um problema operacional.

Cursores mantêm o custo de cada página baixo e permitem evoluir os componentes internos por trás de um token opaco. O guia de paginação de API REST aborda convenções de URL e cabeçalhos em detalhes.

Exportações e jobs de sincronização: cursor

Um job que importa 2 milhões de pedidos precisa de duas garantias:

  1. nenhuma linha perdida apesar de escritas concorrentes;
  2. custo fixo por página.

O offset não oferece nenhuma das duas. Um cursor também fornece um ponto de retomada caso o job falhe na linha 1,4 milhão.

A regra de ouro é simples: offset para interfaces pequenas, navegadas por humanos e que precisam de contagens; cursor para tudo que for grande, dinâmico ou público.

Como APIs reais lidam com isso

A Stripe usa paginação baseada em cursor. Os endpoints de lista aceitam starting_after — o ID de um objeto — e limit, enquanto as respostas incluem has_more. Para buscar a próxima página de cobranças, você envia o ID da última cobrança recebida. Consulte a documentação de paginação da Stripe.

A API REST do GitHub ainda expõe page e per_page na maioria dos endpoints, com cabeçalhos Link apontando para as páginas seguinte e final. A documentação de paginação do GitHub instrui os clientes a seguir o cabeçalho Link literalmente, em vez de construir URLs manualmente.

Endpoints mais recentes do GitHub migraram para cursores porque caminhadas profundas por offset em repositórios massivos eram problemáticas.

O Slack migrou sua Web API para paginação por cursor e recomenda essa abordagem para todos os novos métodos. Métodos como conversations.history retornam response_metadata.next_cursor; uma string vazia indica que o cliente chegou ao fim. Veja a documentação de paginação do Slack.

Três APIs de alto tráfego apontam na mesma direção: cursores.

Projetando o envelope de resposta

Uma API baseada em cursor depende de um envelope simples e previsível:

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Enter fullscreen mode Exit fullscreen mode

Siga estas quatro regras:

  • Sempre retorne has_more. Clientes não devem inferir o fim a partir de uma página curta; filtros aplicados depois da busca podem reduzir seu tamanho.
  • Retorne next_cursor: null na página final e documente essa convenção. A string vazia usada pelo Slack também funciona, mas não misture as duas.
  • Rejeite cursores inválidos com 400, não com um 200 vazio. Um cursor ilegível indica um erro do cliente e deve ser diagnosticável.
  • Assine ou versione o payload do cursor se ele codificar algo além das chaves de ordenação. Isso simplifica futuras migrações de esquema.

Testando os dois estilos no Apidog

Bugs de paginação aparecem nas fronteiras: última página, página vazia e cursor cuja linha âncora foi excluída. Testes manuais dificilmente cobrem esses casos; um cenário encadeado no Apidog consegue fazê-lo.

Teste de endpoints com cursor

  1. Chame o endpoint e extraia o cursor. Adicione um pós-processador à primeira requisição usando o JSONPath $.next_cursor e armazene o resultado em uma variável como nextCursor. O Apidog permite copiar o JSONPath diretamente do painel de resposta. Veja como definir asserções e extrair variáveis com JSONPath.
  2. Execute a requisição da próxima página em um passo ForEach ou de loop. Passe {{nextCursor}} como parâmetro, extraia novamente $.next_cursor a cada iteração e encerre quando has_more for falso.
  3. A cada passagem, verifique que nenhum id se repete em relação à página anterior e que o tamanho da página nunca excede limit.

Teste de endpoints com offset

Use a mesma estrutura com uma variável de contador:

  • incremente page;
  • verifique que o tamanho de data é igual a per_page, exceto na página final;
  • verifique que total permanece consistente durante toda a caminhada.

Cubra os casos de borda

Adicione cenários independentes com asserções explícitas:

  • Página vazia: solicite um filtro que corresponda a zero linhas e confirme que data é [], has_more é false e o status é 200.
  • Cursor inválido: envie cursor=not-a-real-cursor e confirme o status 400 e um código de erro legível por máquina.
  • Linha âncora excluída: crie um pedido, obtenha um cursor ancorado nele, exclua o pedido e use o cursor. Confirme que a caminhada continua da posição correta, sem erro.

Comparações de keyset não exigem que a linha âncora exista; esse teste comprova o comportamento esperado.

Depois de validar o cenário localmente, execute-o no CI a cada merge. Você pode baixar o Apidog gratuitamente e configurar um cenário completo de caminhada por cursor, incluindo loops e asserções, em menos de meia hora.

FAQ

A paginação por cursor é sempre melhor?

Não. Offset é mais adequado quando os usuários precisam de números de página, totais e acesso aleatório a um conjunto de dados moderado — cenário comum em ferramentas administrativas internas.

Cursores são mais adequados quando o conjunto de dados é grande, as escritas são frequentes ou a API é pública. O erro é adotar offset por padrão em um endpoint público e descobrir o custo O(n) depois do lançamento.

Como obter uma contagem total com paginação por cursor?

Execute um SELECT COUNT(*) separado com os mesmos filtros. Você pode expor essa contagem em um endpoint distinto ou em um parâmetro opcional, como include_count=true.

Armazene o resultado em cache agressivamente. Uma contagem aproximada, atualizada a cada minuto, atende à maioria das interfaces. A Stripe ignora totalmente os totais, o que mostra com que frequência os clientes realmente precisam deles.

Posso oferecer os dois estilos em um único endpoint?

Pode, e o GitHub faz isso durante sua transição. Porém, evite essa abordagem em APIs novas: dois estilos significam dois conjuntos de casos de borda, duas matrizes de teste e mais dúvidas para os clientes.

Prefira um estilo por endpoint. Se estiver projetando o contrato do zero, use as convenções do guia de paginação de API REST para manter os nomes dos parâmetros consistentes.

O que acontece se a linha âncora do cursor for excluída?

Nada quebra. A comparação:

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

não exige que a linha âncora ainda exista. Ela busca a posição limite e continua a partir dali.

Essa é uma vantagem importante em relação a designs que tratam o cursor como uma pesquisa direta da linha. É também um caso de borda que vale a pena validar no seu cenário do Apidog antes que um consumidor encontre o problema em produção.

Top comments (0)