Por que paginação é obrigatória em sistemas reais

Retornar todos os registros de uma vez é inviável em qualquer escala

Uma API que retorna todos os usuários de um sistema com 1 milhão de registros em uma única resposta é um problema de múltiplas dimensões: o banco executa uma query que varre toda a tabela, o servidor serializa 1 milhão de objetos JSON, transfere centenas de megabytes pela rede, e o cliente precisa alocar memória para armazenar tudo isso. O resultado prático é timeout, out of memory no servidor, e UI que trava ao tentar renderizar. Paginação é o mecanismo que divide esse volume em páginas de tamanho controlado, servindo apenas o que o usuário precisa ver naquele momento.

Offset Pagination — a abordagem mais simples

Pular N registros e retornar os próximos M — intuitivo mas com problemas em escala

Offset pagination é a implementação mais comum: especificar um offset (quantos registros pular) e um limit (quantos retornar). Em SQL: SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 200 retorna registros 201 a 220. É fácil de implementar e permite navegação direta para qualquer página. O problema aparece em escala: OFFSET 10000 faz o banco percorrer e descartar 10000 registros antes de retornar os próximos 20 — quanto maior o offset, mais lento fica. Em tabelas com milhões de registros, página 500 pode levar segundos enquanto página 1 leva milissegundos. Além disso, dados inseridos ou removidos entre uma requisição de página e a próxima podem causar registros duplicados ou pulados.

Cursor Pagination — navegação estável para feeds e listas dinâmicas

Um cursor opaco que representa a posição no conjunto de dados

Cursor pagination usa um ponteiro (cursor) para a última posição vista em vez de offset numérico. O servidor retorna um cursor junto com os dados; o cliente envia o cursor na próxima requisição para obter os registros seguintes. O cursor tipicamente é o ID ou timestamp do último registro retornado, codificado em base64 (tornando-o opaco e desaconselhando o cliente a interpretá-lo). A query SQL equivalente: SELECT * FROM posts WHERE id > last_cursor_id ORDER BY id LIMIT 20. Vantagens: performance constante independente de quantas páginas já foram navegadas, e resistência a inserções e remoções (o cursor aponta para uma posição específica no conjunto de dados).

Keyset Pagination — cursor baseado nos valores das colunas de ordenação

A alternativa mais performática para qualquer conjunto de dados ordenado

Keyset pagination é uma variação do cursor pagination onde o cursor é composto pelos valores das colunas de ordenação do último registro. Para uma lista de posts ordenados por (published_at DESC, id DESC), o cursor seria (published_at_value, id_value), e a query seria WHERE (published_at, id) < (cursor_published_at, cursor_id) ORDER BY published_at DESC, id DESC LIMIT 20. O banco usa o índice existente diretamente sem precisar percorrer registros descartados, resultando em performance O(1) independente da página. É especialmente eficiente quando a coluna de ordenação tem índice composto e o conjunto de dados é grande.

Paginação bidirecional — navegação para frente e para trás

Como implementar next e previous com cursor pagination

Cursor pagination nativo navega apenas para frente. Para suportar previous (página anterior), o servidor retorna dois cursors: after (para próxima página) e before (para página anterior). A query para previous usa a direção oposta: WHERE id < before_cursor ORDER BY id DESC LIMIT 20, invertendo o resultado para manter a ordem original. APIs como a GraphQL Cursor Connections Spec padronizam esse comportamento com campos pageInfo.hasNextPage, pageInfo.hasPreviousPage, pageInfo.startCursor e pageInfo.endCursor — tornando o contrato de paginação consistente entre diferentes recursos da API.

Paginação em APIs REST — design do contrato

Como expor paginação de forma consistente e navegável

Para APIs REST, os padrões mais comuns são: query params page e per_page (offset) ou after_cursor e limit (cursor), resposta com metadados de paginação no body (total, page, per_page, next_cursor) ou via headers Link (RFC 5988 — GitHub usa exatamente esse formato: Link: URL; rel="next", URL; rel="prev"). A resposta deve sempre informar se há mais páginas (has_more, has_next_page ou presença de next_cursor). Nunca omita metadados de paginação — o cliente precisa saber quando parou de paginar. Evite retornar o total de registros por padrão em paginação por cursor (contar todos os registros é caro), a menos que seja explicitamente solicitado.

Paginação no banco de dados — índices e performance

Por que o índice certo transforma segundos em milissegundos

A performance de paginação no banco de dados depende quase inteiramente de índices. Para offset pagination com ORDER BY id: índice em id é suficiente. Para cursor pagination com WHERE id > cursor ORDER BY id: índice em id. Para keyset com WHERE (published_at, id) em conjunto: índice composto em (published_at DESC, id DESC). Sem índice adequado, cada query de paginação faz full table scan — inaceitável em tabelas grandes. Ao implementar paginação, sempre use EXPLAIN ANALYZE para verificar que a query usa o índice esperado, especialmente em ambientes de produção com volume real de dados.

Paginação com filtros — o desafio da seletividade

Filtros que reduzem muito o resultado podem ser mais lentos que esperado

Ao combinar paginação com filtros (WHERE status = 'active' AND created_at > '2024-01-01'), a seletividade dos filtros afeta drasticamente a performance. Se o filtro é muito seletivo (retorna 0.1% dos registros), o índice de filtro é muito eficiente; se é pouco seletivo (retorna 90% dos registros), o banco pode preferir full scan. Índices compostos que cobrem tanto o filtro quanto a ordenação (covering index) são a solução mais eficiente: CREATE INDEX ON posts(status, created_at, id) para uma query com WHERE status = $1 AND created_at > $2 ORDER BY created_at DESC, id DESC. Testar com volume real de dados — a performance de paginação com filtros raramente é a mesma em desenvolvimento e produção.

Scroll infinito vs paginação clássica — UX e implicações técnicas

Scroll infinito precisa de cursor pagination; paginação clássica funciona com offset

Scroll infinito carrega a próxima página conforme o usuário rola para baixo — é ideal para feeds de conteúdo como redes sociais. Cursor pagination é a implementação correta: ao chegar ao final da lista, enviar o cursor da última mensagem e acrescentar os novos itens. Paginação clássica com botões de página numericada é mais adequada para resultados de busca e tabelas administrativas onde o usuário pode querer ir diretamente à página 5. A implementação técnica é diferente: scroll infinito acumula resultados no cliente, enquanto paginação clássica substitui o conjunto atual. Ambas precisam de indicação clara de carregamento e tratamento de estado vazio (fim dos dados).

Conclusão — paginação correta é experiência e performance juntas

A diferença entre uma API que escala e uma que colapsa com volume crescente

Paginação não é um detalhe de implementação de API — é uma decisão arquitetural que define como o sistema lida com crescimento de dados. Offset é simples mas degrada com escala. Cursor pagination é performático e estável. A escolha depende do caso de uso: paginação aleatória (offset), feeds e listas sequenciais (cursor), performance crítica (keyset). Implemente paginação antes de ter dados em volume — é muito mais difícil adicionar depois que a API está em produção com clientes consumindo. Continue em: Fundamentos obrigatórios antes de produção.

Paginação e APIs — Vídeos Essenciais

Conceitos-chave

Offset Pagination

LIMIT + OFFSET no SQL — simples mas degrada com páginas altas (banco percorre registros descartados).

Cursor Pagination

Usa o ID do último registro como cursor — performance constante em qualquer profundidade de página.

Keyset Pagination

Cursor baseado nos valores das colunas de ordenação — o mais performático para grandes volumes.

after_cursor

Cursor para buscar a próxima página — ponteiro para o registro após o último visto.

Índice Composto

Índice que cobre tanto a coluna de filtro quanto a de ordenação — essencial para paginação com filtros eficiente.

has_next_page

Campo na resposta que indica se há mais páginas disponíveis — obrigatório no contrato de paginação.

Sistemas Distribuídos no Instagram

@bytebytego

Reels — Arquitetura e Backend

@bytebytego

ByteByteGo no Facebook

Sistemas em Produção no X

@mjovanovictech

Como testar resiliência de sistemas em produção real

Ver post completo no X →
@mjovanovictech

Padrões de resiliência em .NET Core com exemplos

Ver post completo no X →
@mjovanovictech

Arquitetura de software orientada a domínio

Ver post completo no X →
@mjovanovictech

Lições de 5 anos mantendo sistemas em produção

Ver post completo no X →
@mjovanovictech

Design de APIs resilientes para produção

Ver post completo no X →
@mjovanovictech

Microsserviços vs monolito — como escolher

Ver post completo no X →

O que dizem

Alexandre P. ★★★★★

Migrar de offset para cursor pagination foi o que desbloqueou a performance da nossa API de listagem de transações. Com 3 milhões de registros, página 100 com offset levava 4 segundos; com cursor pagination ficou em 5ms constante. A diferença é absurda e o artigo explica exatamente o porquê.

Leticia F. ★★★★★

O detalhe sobre índice composto cobrindo filtro e ordenação é ouro. EXPLAIN ANALYZE antes e depois da criação do índice mostrou a diferença entre Seq Scan (7s) e Index Scan (2ms) na mesma query. Fundamental para qualquer paginação com filtros em produção.

Marcos V. ★★★★☆

Ótimo artigo. Complementando: para totais de registros com filtros em paginação, use COUNT separado e cacheado — não retorne o total em cada página. Um COUNT(*) em tabela com milhões de registros pode ser mais lento que a própria query paginada.