Offset vs Cursor

Paginação parece um detalhe de implementação, mas em sistemas com milhões de registros, ela se torna uma decisão arquitetural crítica. Em APIs que lidam com grandes volumes de dados, a estratégia de paginação impacta diretamente performance, consistência, custo computacional e escalabilidade.

Neste artigo analiso três abordagens comuns:

  1. Paginação por número de página (page number)
  2. Paginação por offset
  3. Paginação por cursor (keyset pagination)

O cenário testado:

  • API Java + Spring Boot (mas linguagem e framework são indiferentes para a análise)
  • Banco PostgreSQL
  • Tabela com 10 milhões de registros
  • Consultas ordenadas por updated_at (timestamp), id
  • Índice composto (updated_at, id)
  • Operações de resync incremental entre sistemas, retornando centenas de milhares de registros paginados

incluí um benchmark com EXPLAIN ANALYZE para maior detalhamento.

Vamos lá então? ...


Paginação por Número de Página (Page Number)

O que é

O cliente informa:

GET /resources?page=5&size=100

Internamente, o backend converte isso para:

SELECT *
FROM resource
ORDER BY updated_at
LIMIT 100 OFFSET 400;

Ou seja: offset = (page - 1) * size


Complexidade, Performance e Custo para o PostgreSQL

Mesmo com índice em updated_at:

CREATE INDEX idx_resource_updated_at ON resource(updated_at);

O PostgreSQL precisa:

  1. Percorrer N registros
  2. Descartar os primeiros registros de acordo com OFFSET
  3. Retornar os próximos registros dentro do LIMIT

Se você pedir: page=5000&size=100, que resultará no Offset=499900.
O banco precisa percorrer ~500 mil registros para retornar 100.

Complexidade aproximada

  • Tempo ≈ O(n) em relação ao offset
  • Quanto maior a página, pior a performance

Com 10 milhões de registros:

  • Últimas páginas ficam extremamente lentas
  • Pode gerar alto uso de CPU e I/O

Mas quando ainda faz sentido usar?

✔ Interfaces de usuário
✔ Tabelas administrativas
✔ Dados pequenos ou estáticos

Nestas outras situações é recomendado evitar tal abordagem:

❌ Resync de larga escala
❌ Exportações massivas
❌ Streams contínuos


Paginação por Offset (Explícito)

Na prática é a mesma coisa que page number, mas a API expõe:

GET /resources?offset=400000&limit=100

SQL

SELECT *
FROM resource
ORDER BY updated_at
LIMIT 100 OFFSET 400000;

Diferença real?

Do ponto de vista do banco: nenhuma.

A diferença é apenas de contrato da API.

Esta abordagem apresenta os mesmos problemas da anterior:

  • OFFSET alto → varredura grande
  • Custo linear crescente

Problemas de Deslocamento

Um problema adicional das duas abordagens anteriores é causado por possíveis deslocamentos na ordenação dos itens.

Imagine que a ordenação é pelo campo updated_at e que você irá buscar duas paginas de 10 itens cada, totalizando 20 itens.

Você buscou a primeira pagina com os itens 1 ao 10. Mas antes de buscar a segunda pagina, ocorreu uma atualização no item 8 (ou qualquer um dos itens da primeira pagina).

O item 8 teve seu updated_at atualizado para um valor mais recente que fez com que ela seja deslocado para o final da lista de itens, sendo agora o item de numero 20.

O problema é que o item 11, que deveria ser o primeiro da segunda página, foi deslocado agora para a posição 10. Quando você buscar a segunda página o antigo item 11 não está nela, pois foi descolocado para a pagina anterior. Neste caso você nunca buscará o antigo item 11 (agora item 10).

Isto tem o potencial de gerar graves inconsistências no resync.


Paginação por Cursor (Keyset Pagination)

O que é

Ao invés de pular N registros, você usa o último valor da ordenação como ponto de continuidade.

Exemplo:

GET /resources?cursor=2025-02-01T10:15:33Z&limit=100

SQL

SELECT *
FROM resource
WHERE updated_at > :cursor
ORDER BY updated_at ASC
LIMIT 100;

Mas, há um problema oculto nesta abordagem: Caso a ordenação seja apenas por updated_at, há o risco de itens atualizados no mesmo microssegundo (em casos de atualizações em bulk por exemplo) sejam "saltados" indevidamente.
Algumas estratégia para evitar este problema:

Fazer a ordenação de forma composta, incluindo um campo como o ID:

Índice recomendado para performance ideal:

CREATE INDEX idx_resource_updated_at_id 
ON resource(updated_at, id);
SELECT *
FROM resource
WHERE (updated_at, id) > (:updated_at, :id)
ORDER BY updated_at, id
LIMIT 100;

Neste cenário, para se realizar a consulta será necessário dois campos para gerar um cursor composto (:updated_at, :id).
Mas se preferir ocultar esta complexidade do consumidor da API, ainda é possível encapsular estas duas informações em um único token, usando Base64 ou mesmo encapsular em um UUID.

Outra estratégia seria ter no lugar de um simples updated_at como um timestamp, ter um update_id (update identifier) como um campo ULID. Este tipo de campo manteria um componente temporal ordenável, mas resolveria conflitos com um componente randômico.
Mas, importante, essa abordagem só é válida se o ULID for gerado a cada atualização, substituindo semanticamente o updated_at como critério de ordenação.


Vamos aos aspectos de performance desta abordagem com Cursor

Com índice adequado:

  • Busca via range scan no índice
  • Complexidade ≈ O(log n) para localizar início
  • Depois apenas leitura sequencial do índice

Performance praticamente constante para profundidades grandes, pois o custo dominante passa a ser O(log n), que cresce muito lentamente.


Cursor e Resync entre Sistemas

Este é o cenário ideal para cursor.

Fluxo típico:

  1. Sistema B armazena último (updated_at, id)
  2. Chama API:
    GET /resources?cursor=lastSeen
    
  3. Processa lote
  4. Atualiza checkpoint
  5. Continua

Isso garante:

✔ Não pula registros
✔ Não duplica registros
✔ Escala para milhões
✔ Funciona com dados mutáveis


Consistência e Concorrência

Offset

Problema:

Página 1 → registros 1–100
Página 2 → registros 101–200

Se um registro entre 1–100 for atualizado e movido no sort:

  • Pode reaparecer
  • Pode causar buraco

Cursor

Cursor baseado em:

WHERE updated_at > last_seen

Não depende de posição.

Depende apenas do valor ordenado.

Muito mais robusto.


Impacto de I/O e Uso de Memória

Offset grande

  • Leitura grande de índice
  • Descarte massivo
  • Aumento de buffer usage
  • Pode causar read amplification

Cursor

  • Apenas range scan
  • Leitura mínima
  • Melhor uso de cache

Teste prático

O Problema do OFFSET

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, updated_at
FROM resource
ORDER BY updated_at, id
LIMIT 100 OFFSET 5000000;
Limit
  -> Index Only Scan using idx_resource_updated_at_id
     rows=5000100
     Heap Fetches: 0
     Buffers: shared hit=3502223

Execution Time: 711 ms

O que aconteceu de fato?

Mesmo usando:

  • Índice perfeito
  • Index Only Scan
  • Nenhum heap fetch
  • Dados já em cache

O banco precisou:

  • Percorrer 5.000.100 registros
  • Descartar 5.000.000
  • Retornar apenas 100

Buffers acessados: 3.502.223 páginas

Isso significa milhões de acessos a estruturas internas de índice.

OFFSET é baseado em posição lógica

Quando executamos:

SELECT id, updated_at
FROM resource
ORDER BY updated_at, id
LIMIT 100 OFFSET 5000000;

O PostgreSQL precisa:

  1. Percorrer o índice ordenado.
  2. Avançar por 5.000.000 entradas.
  3. Descartá-las.
  4. Retornar as próximas 100.

Ou seja, o banco processou milhões de entradas para entregar apenas 100 registros.

Se o OFFSET dobra, o tempo praticamente dobra.

Mesmo no melhor cenário possível (index-only, sem I/O de disco), o custo é alto.


Cursor (Keyset Pagination)

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, updated_at
FROM resource
WHERE (updated_at, id) > ('2025-09-02 11:34:49.908717', 3736661)
ORDER BY updated_at, id
LIMIT 100;
Limit
  -> Index Only Scan using idx_resource_updated_at_id
     Index Cond: (ROW(updated_at, id) > (...))
     Heap Fetches: 0
     Buffers: shared hit=71 read=1

Execution Time: 0.057 ms

O que aconteceu aqui?

O PostgreSQL:

  1. Navegou pela B-tree até localizar a folha correta (custo O(log n))
  2. Localizou o ponto exato
  3. Leu apenas 100 registros

Buffers acessados: 72 páginas

EstratégiaBuffers
OFFSET 5M3.502.223
Cursor72

Diferença: ~48.000x menos páginas acessadas

Cursor usa range scan indexado: T(n) ≈ O(log n + k)
Onde:

  • n = tamanho da tabela
  • k = tamanho da página

O tempo permanece praticamente constante em relação à profundidade da navegação, mesmo com 10 milhões de registros.


Comparação Final

CritérioOFFSETCursor
ComplexidadeO(n)O(log n + k)
Buffers (5M)3.5M72
Tempo real711 ms0.057 ms
Escala com profundidadeLinearLogarítmica
Estável sob mutaçãoNãoSim
Ideal para UISimParcial
Ideal para ResyncNãoSim

Conclusão

Em um dataset de 10 milhões de registros:

  • OFFSET percorre milhões de entradas
  • Cursor inicia exatamente no ponto necessário
  • A diferença pode passar de 10.000x
  • O custo do OFFSET é estrutural, não circunstancial

Para sistemas que fazem:

  • Resync
  • Exportações massivas
  • Integração entre serviços
  • Processamento incremental

Keyset Pagination é requisito arquitetural e não meramente uma otimização


OFFSET não é ineficiente por má implementação, ele é estruturalmente linear devido à forma como índices B-tree funcionam.
Quando OFFSET Ainda Faz Sentido?

✔ Interfaces administrativas
✔ Tabelas pequenas
✔ Dados quase estáticos
✔ Quando é necessário pular para página arbitrária

Mas não em sistemas de larga escala.


Os resultados empíricos deixam claro:

OFFSET degrada linearmente mesmo no melhor cenário possível. Cursor mantém performance estável em relação à profundidade da paginação, mesmo com milhões de registros.

Se você está projetando uma API com PostgreSQL e espera crescer além de alguns milhões de linhas, a decisão de paginação precisa ser tomada desde o início.


Por hoje é isto ...

Artus