Paginação por cursor vs offset para APIs rápidas de telas administrativas
Entenda paginação por cursor vs offset com um contrato de API consistente para ordenação, filtros e totais que mantém telas administrativas rápidas na web e no móvel.

Por que a paginação pode fazer telas administrativas parecerem lentas
Telas administrativas frequentemente começam como uma tabela simples: carregue as primeiras 25 linhas, adicione uma caixa de busca, pronto. Parece instantâneo com algumas centenas de registros. Então o conjunto de dados cresce, e a mesma tela começa a travar.
O problema usual não é a interface. É o que a API precisa fazer antes de retornar a página 12 com ordenação e filtros aplicados. À medida que a tabela aumenta, o backend gasta mais tempo encontrando correspondências, contando-as e pulando resultados anteriores. Se cada clique dispara uma consulta mais pesada, a tela parece estar pensando em vez de responder.
Você tende a notar isso nos mesmos lugares: mudanças de página ficam mais lentas com o tempo, a ordenação gira devagar, a busca fica inconsistente entre páginas e o infinite scroll carrega em rajadas (rápido, depois de repente lento). Em sistemas movimentados você pode até ver duplicatas ou linhas faltando quando dados mudam entre requisições.
UIs web e móveis também empurram a paginação em direções diferentes. Uma tabela administrativa web incentiva pular para uma página específica e ordenar por muitas colunas. Telas móveis geralmente usam uma lista infinita que carrega o próximo bloco, e os usuários esperam que cada carregamento seja igualmente rápido. Se sua API foi construída apenas em torno de números de página, o móvel costuma sofrer. Se for construída apenas em torno de next/after, tabelas web podem ficar limitadas.
O objetivo não é apenas retornar 25 itens. É paginação rápida e previsível que se mantém estável conforme os dados crescem, com regras que funcionam da mesma forma para tabelas e listas infinitas.
Noções básicas de paginação das quais sua UI depende
Paginação é dividir uma lista longa em pedaços menores para que a tela possa carregar e renderizar rapidamente. Em vez de pedir todo o registro para a API, a UI solicita o próximo segmento de resultados.
O controle mais importante é o tamanho da página (geralmente chamado limit). Páginas menores costumam parecer mais rápidas porque o servidor faz menos trabalho e o app desenha menos linhas. Mas páginas muito pequenas podem parecer saltitantes porque os usuários precisam clicar ou rolar com mais frequência. Para muitas tabelas administrativas, 25 a 100 itens é um intervalo prático, com o móvel geralmente preferindo o extremo inferior.
Uma ordem de classificação estável importa mais do que a maioria das equipes espera. Se a ordem pode mudar entre requisições, os usuários veem duplicatas ou linhas faltando ao paginar. Ordenação estável geralmente significa ordenar por um campo primário (como created_at) mais um tie-breaker (como id). Isso importa tanto faz usar paginação por offset quanto por cursor.
Do ponto de vista do cliente, uma resposta paginada deve incluir os itens, uma dica de próxima página (número de página ou token cursor) e apenas as contagens que a UI realmente precisa. Algumas telas precisam de um total exato para “1–50 de 12.340”. Outras só precisam de has_more.
Paginação por offset: como funciona e onde dói
A paginação por offset é a abordagem clássica de página N. O cliente pede um número fixo de linhas e diz à API quantas linhas pular primeiro. Você a verá como limit e offset, ou como page e pageSize que o servidor converte em offset.
Uma requisição típica se parece com isto:
GET /tickets?limit=50\u0026offset=950- “Me dê 50 tickets, pulando os primeiros 950.”
Isso atende necessidades comuns administrativas: pular para a página 20, investigar registros antigos ou exportar uma grande lista em blocos. Também é fácil de explicar internamente: “Veja a página 3 e você verá aquilo.”
O problema aparece em páginas profundas. Muitos bancos ainda precisam percorrer as linhas puladas antes de retornar sua página, especialmente quando a ordenação não é suportada por um índice eficaz. A página 1 pode ser rápida, mas a página 200 pode ficar visivelmente mais lenta, e é exatamente isso que faz telas administrativas parecerem lentas quando usuários rolam ou pulam por aí.
Outro problema é a consistência quando os dados mudam. Imagine um gerente de suporte abrindo a página 5 de tickets ordenados pelos mais recentes. Enquanto olha, novos tickets chegam ou tickets antigos são apagados. Inserções podem deslocar itens para frente (duplicatas entre páginas). Exclusões podem deslocar itens para trás (registros desaparecem do caminho do usuário).
A paginação por offset ainda pode ser adequada para tabelas pequenas, datasets estáveis ou exportações pontuais. Em tabelas grandes e ativas, os casos de borda aparecem rapidamente.
Paginação por cursor: como funciona e por que se mantém estável
A paginação por cursor usa um cursor como marcador. Em vez de dizer “me dê a página 7”, o cliente diz “continue depois deste item exato”. O cursor geralmente codifica os valores de ordenação do último item (por exemplo, created_at e id) para que o servidor possa retomar do lugar certo.
A requisição costuma ser apenas:
limit: quantos itens retornarcursor: um token opaco da resposta anterior (frequentemente chamadoafter)
A resposta retorna itens mais um novo cursor que aponta para o fim daquele segmento. A diferença prática é que cursors não pedem ao banco que conte e pule linhas. Eles pedem para começar a partir de uma posição conhecida.
Por isso a paginação por cursor se mantém rápida para listas que avançam. Com um índice bom, o banco pode saltar para “itens depois de X” e então ler as próximas limit linhas. Com offsets, o servidor costuma ter que varrer (ou ao menos pular) cada vez mais linhas conforme o offset cresce.
Para o comportamento da UI, a paginação por cursor torna o “Próximo” natural: você pega o cursor retornado e o envia na próxima requisição. “Anterior” é opcional e mais complicado. Algumas APIs suportam um cursor before, enquanto outras buscam em ordem reversa e invertem os resultados.
Quando escolher cursor, offset ou um híbrido
A escolha começa com como as pessoas realmente usam a lista.
A paginação por cursor se encaixa melhor quando os usuários avançam na maioria das vezes e a velocidade é o principal objetivo: logs de atividade, chats, pedidos, tickets, trilhas de auditoria e a maioria do infinite scroll móvel. Ela também se comporta melhor quando novas linhas são inseridas ou deletadas enquanto alguém está navegando.
A paginação por offset faz sentido quando os usuários frequentemente pulam: tabelas administrativas clássicas com números de página, ir-para-página e navegação rápida entre páginas. É simples de explicar, mas pode ficar mais lenta em datasets grandes e menos estável quando os dados mudam por baixo de você.
Uma forma prática de decidir:
- Escolha cursor quando a ação principal for “próximo, próximo, próximo.”
- Escolha offset quando “pular para a página N” for um requisito real.
- Trate totais como opcionais. Contagens precisas podem ser caras em tabelas enormes.
Híbridos são comuns. Uma abordagem é next/prev baseado em cursor para velocidade, mais um modo opcional de pular para página em subconjuntos pequenos e filtrados onde os offsets permanecem rápidos. Outra é recuperação por cursor com números de página baseados em um snapshot em cache, para que a tabela pareça familiar sem transformar cada requisição em trabalho pesado.
Um contrato de API consistente que funciona na web e no móvel
Telas administrativas parecem mais rápidas quando todo endpoint de lista se comporta igual. A UI pode mudar (tabela web com números de página, infinite scroll móvel), mas o contrato da API deve permanecer estável para que você não reaprenda as regras de paginação para cada tela.
Um contrato prático tem três partes: linhas, estado de paginação e totais opcionais. Mantenha os nomes idênticos entre endpoints (tickets, users, orders), mesmo que o modo de paginação subjacente difira.
Aqui está uma forma de resposta que funciona bem tanto para web quanto para móvel:
{
"data": [ { "id": "...", "createdAt": "..." } ],
"page": {
"mode": "cursor",
"limit": 50,
"nextCursor": "...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false
},
"totals": {
"count": 12345,
"filteredCount": 120
}
}
Alguns detalhes tornam isso fácil de reaproveitar:
page.modediz ao cliente o que o servidor está fazendo sem mudar nomes de campos.limité sempre o tamanho de página solicitado.nextCursoreprevCursorestão presentes mesmo que um sejanull.totalsé opcional. Se for caro, devolva apenas quando o cliente pedir.
Uma tabela web ainda pode mostrar “Página 3” mantendo seu próprio índice de página e chamando a API repetidamente. Uma lista móvel pode ignorar números de página e simplesmente pedir o próximo bloco.
Se você está construindo tanto web quanto móvel em AppMaster, um contrato estável como este compensa rápido. O mesmo comportamento de lista pode ser reutilizado em telas sem lógica de paginação customizada por endpoint.
Regras de ordenação que mantêm a paginação estável
A ordenação é onde a paginação costuma quebrar. Se a ordem pode mudar entre requisições, usuários veem duplicatas, lacunas ou linhas “faltando”.
Faça da ordenação um contrato, não uma sugestão. Publique os campos e direções de ordenação permitidos e rejeite qualquer outro. Isso mantém sua API previsível e evita que clientes peçam ordenações lentas que parecem inofensivas em desenvolvimento.
Uma ordenação estável precisa de um tie-breaker único. Se você ordena por created_at e dois registros têm o mesmo timestamp, acrescente id (ou outra coluna única) como última chave. Sem isso, o banco é livre para devolver valores iguais em qualquer ordem.
Regras práticas que se mantêm:
- Permita ordenar apenas em campos indexados e bem definidos (por exemplo
created_at,updated_at,status,priority). - Inclua sempre um tie-breaker único como chave final (por exemplo
id ASC). - Defina uma ordenação padrão (por exemplo
created_at DESC, id DESC) e mantenha-a consistente entre clientes. - Documente como nulos são ordenados (por exemplo “nulls last” para datas e números).
A ordenação também guia a geração do cursor. Um cursor deve codificar os valores de ordenação do último item na ordem correta, incluindo o tie-breaker, para que a próxima página possa consultar “after” aquela tupla. Se a ordenação mudar, cursors antigos ficam inválidos. Trate parâmetros de ordenação como parte do contrato do cursor.
Filtros e totais sem quebrar o contrato
Filtros devem parecer separados da paginação. A UI está dizendo “mostre um conjunto diferente de linhas” e só então pergunta “pageie esse conjunto”. Se você misturar campos de filtro no token de paginação ou tratar filtros como opcionais e não validados, você terá comportamentos difíceis de depurar: páginas vazias, duplicatas ou um cursor que aponta de repente para um conjunto diferente.
Uma regra simples: filtros vivem em query params simples (ou no corpo de requisição para POST), e o cursor é opaco e válido apenas para aquela combinação exata de filtro + ordenação. Se o usuário mudar qualquer filtro (status, intervalo de datas, responsável), o cliente deve descartar o cursor antigo e começar do início.
Seja rigoroso sobre que filtros são permitidos. Isso protege o desempenho e mantém o comportamento previsível:
- Rejeite campos de filtro desconhecidos (não os ignore silenciosamente).
- Valide tipos e intervalos (datas, enums, IDs).
- Limite filtros amplos (por exemplo, máximo 50 IDs em um
IN). - Aplique os mesmos filtros aos dados e aos totais (sem números incompatíveis).
TotaIs são onde muitas APIs ficam lentas. Contagens exatas podem ser caras em tabelas grandes, especialmente com múltiplos filtros. Geralmente há três opções: exato, estimado ou nenhum. Exato é ótimo para datasets pequenos ou quando o usuário precisa realmente de “mostrando 1–25 de 12.431”. Estimado costuma ser suficiente para telas administrativas. Nenhum é aceitável quando você só precisa de “Carregar mais”.
Para evitar tornar cada requisição lenta, torne totais opcionais: calcule-os apenas quando o cliente pedir (por exemplo com uma flag includeTotal=true), cacheie por pouco tempo por conjunto de filtros, ou retorne totais apenas na primeira página.
Passo a passo: projetar e implementar o endpoint
Comece com padrões. Um endpoint de lista precisa de uma ordenação estável, mais um tie-breaker para linhas que compartilham o mesmo valor. Por exemplo: createdAt DESC, id DESC. O tie-breaker (id) evita duplicatas e lacunas quando novos registros são adicionados.
Defina uma única forma de requisição e mantenha-a simples. Parâmetros típicos são limit, cursor (ou offset), sort e filters. Se você suportar ambos os modos, torne-os mutuamente exclusivos: ou o cliente envia cursor, ou envia offset, mas não ambos.
Mantenha um contrato de resposta consistente para que UIs web e móveis possam compartilhar a mesma lógica de lista:
items: a página de registrosnextCursor: o cursor para buscar a próxima página (ounull)hasMore: booleano para a UI decidir mostrar “Carregar mais”total: total de registros que batem (nulla menos que solicitado se a contagem for cara)
A implementação é onde as duas abordagens divergem.
Consultas por offset geralmente são ORDER BY ... LIMIT ... OFFSET ..., o que pode ficar lento em tabelas grandes.
Consultas por cursor usam condições de busca baseadas no último item: “me dê items onde (createdAt, id) é menor que o último (createdAt, id)”. Isso mantém o desempenho mais estável porque o banco pode usar índices.
Antes de lançar, adicione guardrails:
- Limite o
limit(por exemplo, máximo 100) e defina um padrão. - Valide
sortcontra uma allowlist. - Valide filtros por tipo e rejeite chaves desconhecidas.
- Torne o
cursoropaco (codifique os últimos valores de ordenação) e rejeite cursors malformados. - Decida como
totalé solicitado.
Teste com dados mudando por baixo de você. Crie e delete registros entre requisições, atualize campos que afetam a ordenação e verifique se você não vê duplicatas ou linhas faltando.
Exemplo: lista de tickets que se mantém rápida na web e no móvel
Uma equipe de suporte abre uma tela administrativa para revisar os tickets mais recentes. Eles precisam que a lista pareça instantânea, mesmo enquanto novos tickets chegam e agentes atualizam tickets antigos.
Na web, a UI é uma tabela. A ordenação padrão é por updated_at (mais recente primeiro), e a equipe frequentemente filtra para Open ou Pending. O mesmo endpoint pode suportar ambas as ações com uma ordenação estável e um token cursor.
GET /tickets?status=open\u0026sort=-updated_at\u0026limit=50\u0026cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==
A resposta permanece previsível para a UI:
{
"items": [{"id": 1693, "subject": "Login issue", "status": "open", "updated_at": "2026-01-25T11:45:42Z"}],
"page": {"next_cursor": "...", "has_more": true},
"meta": {"total": 128}
}
No móvel, o mesmo endpoint alimenta o infinite scroll. O app carrega 20 tickets por vez e então envia next_cursor para buscar o próximo lote. Sem lógica de número de página e com menos surpresas quando registros mudam.
A chave é que o cursor codifica a última posição vista (por exemplo, updated_at mais id como tie-breaker). Se um ticket for atualizado enquanto o agente rola, ele pode mover-se para o topo na próxima atualização, mas não causará duplicatas ou lacunas no feed já percorrido.
TotaIs são úteis, mas caros em datasets grandes. Uma regra simples é retornar meta.total apenas quando o usuário aplica um filtro (como status=open) ou solicita explicitamente.
Erros comuns que causam duplicatas, lacunas e lentidão
A maioria dos bugs de paginação não está no banco. Eles vêm de pequenas decisões na API que parecem ok em testes e depois desmoronam quando os dados mudam entre requisições.
A causa mais comum de duplicatas (ou linhas faltando) é ordenar por um campo que não é único. Se você ordenar por created_at e dois itens compartilharem o mesmo timestamp, a ordem pode inverter entre requisições. A correção é simples: sempre adicione um tie-breaker estável, normalmente a chave primária, e trate a ordenação como um par como (created_at desc, id desc).
Outro problema comum é deixar os clientes solicitarem qualquer tamanho de página. Uma requisição grande pode disparar picos de CPU, memória e tempos de resposta, o que deixa todas as telas administrativas mais lentas. Escolha um padrão sensato e um máximo rígido, e retorne um erro quando o cliente pedir mais.
TotaIs também podem atrapalhar. Contar todos os registros correspondentes em toda requisição pode se tornar a parte mais lenta do endpoint, especialmente com filtros. Se a UI precisa de totais, busque-os apenas quando solicitado (ou retorne uma estimativa) e evite bloquear o scroll da lista em uma contagem completa.
Erros que frequentemente criam lacunas, duplicatas e lentidão:
- Ordenar sem um tie-breaker único (ordem instável)
- Tamanhos de página ilimitados (sobrecarga do servidor)
- Retornar totais toda vez (consultas lentas)
- Misturar regras de offset e cursor num mesmo endpoint (comportamento confuso)
- Reutilizar o mesmo cursor quando filtros ou ordenação mudam (resultados errados)
Reinicie a paginação sempre que filtros ou ordenação mudarem. Trate um novo filtro como uma nova busca: limpe o cursor/offset e comece pela primeira página.
Checklist rápido antes de lançar
Execute isto uma vez com API e UI lado a lado. A maioria dos problemas aparece no contrato entre a tela de lista e o servidor.
- Ordenação padrão é estável e inclui um tie-breaker único (por exemplo
created_at DESC, id DESC). - Campos e direções de ordenação são whitelistados.
- Um tamanho de página máximo é aplicado, com um padrão sensato.
- Tokens de cursor são opacos, e cursors inválidos falham de forma previsível.
- Qualquer mudança de filtro ou ordenação reinicia o estado da paginação.
- Comportamento de totais é explícito: exato, estimado ou omitido.
- O mesmo contrato suporta tanto tabela quanto infinite scroll sem casos especiais.
Próximos passos: padronize suas listas e mantenha-as consistentes
Escolha uma lista administrativa que as pessoas usem todo dia e faça dela seu padrão ouro. Uma tabela movimentada como Tickets, Orders ou Users é um bom ponto de partida. Quando esse endpoint estiver rápido e previsível, copie o mesmo contrato pelo resto das telas administrativas.
Escreva o contrato, mesmo que seja breve. Seja explícito sobre o que a API aceita e o que retorna para que a equipe de UI não tenha que adivinhar e acabe inventando regras diferentes por endpoint.
Um padrão simples a aplicar em todo endpoint de lista:
- Sorts permitidos: nomes de campo exatos, direção e um padrão claro (mais um tie-breaker como
id). - Filtros permitidos: quais campos podem ser filtrados, formatos de valor e o que acontece em filtros inválidos.
- Comportamento de totais: quando retornar uma contagem, quando retornar “desconhecido” e quando omitir.
- Formato de resposta: chaves consistentes (
items, info de paginação, sort/filtros aplicados, totals). - Regras de erro: códigos de status consistentes e mensagens de validação legíveis.
Se você está construindo essas telas administrativas com AppMaster (appmaster.io), ajuda padronizar o contrato de paginação cedo. Você pode reutilizar o mesmo comportamento de lista entre seu app web e apps móveis nativos, e gastará menos tempo correndo atrás de casos de borda de paginação mais tarde.
FAQ
A paginação por offset usa limit mais offset (ou page/pageSize) para pular linhas, então páginas mais profundas frequentemente ficam mais lentas porque o banco precisa percorrer mais registros. A paginação por cursor usa um token after baseado nos valores de ordenação do último item, permitindo saltar para uma posição conhecida e manter a velocidade à medida que você avança.
Porque a página 1 costuma ser barata, mas a página 200 força o banco a pular muitos registros antes de retornar algo. Se você também aplica ordenação e filtros, o trabalho cresce e cada clique começa a parecer uma consulta pesada em vez de uma busca rápida.
Use sempre uma ordenação estável com um tie-breaker único, por exemplo created_at DESC, id DESC ou updated_at DESC, id DESC. Sem o tie-breaker, registros com o mesmo timestamp podem trocar de ordem entre requisições — essa é uma causa comum de duplicatas e linhas “faltando”.
Prefira paginação por cursor para listas em que as pessoas avançam principalmente e a velocidade importa, como logs de atividade, tickets, pedidos e infinite scroll móvel. Ela se mantém consistente quando novas linhas são inseridas ou deletadas porque o cursor ancora a próxima página numa posição última vista exata.
A paginação por offset é adequada quando “ir para a página N” é um requisito real da interface e os usuários pulam muito entre páginas. Também é conveniente para tabelas pequenas ou datasets estáveis, onde a lentidão em páginas profundas e a mudança de resultados são menos prováveis de impactar.
Mantenha um formato de resposta único entre endpoints e inclua os itens, o estado de paginação e totais opcionais. Um padrão prático é retornar items, um objeto page (com limit, nextCursor/prevCursor ou offset) e uma flag como hasNext para que tanto tabelas web quanto listas móveis possam reaproveitar a mesma lógica cliente.
Porque um COUNT(*) exato em datasets grandes e com filtros pode ser a parte mais lenta da requisição e deixar cada mudança de página lenta. Um padrão mais seguro é tornar os totais opcionais, retorná-los apenas quando solicitados, ou devolver has_more quando a interface só precisa de “Carregar mais”.
Trate filtros como parte do conjunto de dados e o cursor como válido apenas para aquela combinação específica de filtro e ordenação. Se o usuário alterar qualquer filtro ou ordenação, reinicie a paginação e comece pela primeira página; reutilizar um cursor antigo após mudanças costuma gerar páginas vazias ou resultados confusos.
Liste e permita apenas os campos e direções de ordenação aceitáveis, rejeitando qualquer outro pedido para que os clientes não peçam ordenações lentas ou instáveis. Prefira ordenar por campos indexados e sempre acrescente um tie-breaker único, como id, para manter a ordem determinística entre requisições.
Imponha um limit máximo, valide filtros e parâmetros de ordenação, e torne os tokens de cursor opacos e estritamente validados. Se estiver construindo telas administrativas em AppMaster, manter essas regras consistentes em todos os endpoints de lista facilita reutilizar o mesmo comportamento de tabela e infinite scroll sem ajustes pontuais por tela.


