APIs PostgreSQL

Paginação por cursor vs OFFSET: por que sua API degrada na página 500

OFFSET 10000 lê e joga fora 10 mil linhas antes de devolver 20. Paginação por cursor pula direto ao ponto certo pelo índice — e não degrada com a profundidade.

· 11 min de leitura

A primeira página da sua listagem carrega em 8ms. A décima, em 40ms. Ninguém repara. Aí um cliente com muito histórico chega na página 500 e a requisição leva 900ms — e você não entende, porque o LIMIT é sempre 20. Se cada página devolve o mesmo tanto de linhas, por que o fundo do dataset é tão mais lento?

A resposta está numa palavra que parece inofensiva: OFFSET.

O que o OFFSET realmente faz

A intuição é que OFFSET 10000 significa "comece na linha 10.001". A gente imagina o banco pulando direto para lá. Não é o que acontece:

SELECT * FROM pedidos
ORDER BY criado_em DESC
OFFSET 10000 LIMIT 20;

Para o Postgres devolver as linhas 10.001 a 10.020 na ordem certa, ele precisa primeiro produzir as 10.000 linhas anteriores — lê-las, ordena-as — e só então as descarta para começar a contar o seu LIMIT. O banco faz o trabalho de 10.020 linhas para te entregar 20.

OFFSET N é O(N): o custo cresce linearmente com a profundidade da página. Página 1 é barata, página 500 é 500 vezes mais cara. O LIMIT constante esconde isso — você olha "20 linhas" e não vê as 10.000 lidas e jogadas fora antes delas.

O EXPLAIN ANALYZE denuncia na hora: um nó Limit com Rows Removed proporcional ao offset, e um tempo de execução que sobe a cada página mais funda. É o mesmo tipo de trabalho desperdiçado que um Seq Scan faz — só que aqui é auto-infligido pela forma de paginar.

E tem um bug pior que a lentidão

Lentidão você mede. Este outro problema é silencioso e corrói a confiança no seu produto: OFFSET pula e duplica itens quando os dados mudam entre páginas.

Pense num feed ordenado do mais novo para o mais antigo. O usuário carrega a página 1 (itens 1–20). Enquanto ele lê, 3 itens novos são inseridos no topo. Ele pede a página 2 — OFFSET 20:

Momento 1 — usuário vê a página 1:
  [ A B C D ... T ]   (20 itens mais recentes)

Entram 3 itens novos no topo: X Y Z

Momento 2 — usuário pede OFFSET 20 (página 2):
  a lista agora é [ X Y Z A B C ... ]
  OFFSET 20 pula os 20 primeiros → X Y Z A B ... Q
  itens R S T aparecem nas DUAS páginas (duplicados)

O offset é uma posição numa lista que se moveu embaixo dos pés do usuário. Itens repetem, outros somem no vão. Em painéis administrativos isso irrita; em processamento paginado — "percorrer todos os pedidos e faturar" — isso significa cobrar duas vezes ou pular pedidos. O mesmo tipo de dano de um webhook não idempotente, por outra porta.

Paginação por cursor: filtre, não pule

A ideia do keyset (ou cursor) é trocar "pule as N primeiras" por "me dê as próximas depois deste ponto". Em vez de uma posição numérica, você guarda o valor da última linha vista e filtra a partir dele:

-- primeira página: sem cursor
SELECT * FROM pedidos
ORDER BY criado_em DESC
LIMIT 20;

-- próxima página: "mais antigos que o último que eu vi"
SELECT * FROM pedidos
WHERE criado_em < $1          -- criado_em da última linha da página anterior
ORDER BY criado_em DESC
LIMIT 20;

A diferença é estrutural. O WHERE criado_em < $1 usa o índice em criado_em para ir direto ao ponto de corte e ler só 20 linhas. Não há nada para descartar. A página 500 custa o mesmo que a página 1 — O(1) em relação à profundidade, não O(N).

E o problema de deslocamento some junto: como o corte é por valor e não por posição, inserções no topo não afetam a busca do que vem depois de um item específico. O usuário nunca vê duplicata nem pulo.

O detalhe que quebra em produção: empates

O exemplo acima tem um bug esperando acontecer. E se duas linhas têm o mesmo criado_em? Timestamps colidem mais do que se imagina — importações em lote, criações no mesmo instante. Com WHERE criado_em < $1, as linhas empatadas exatamente no valor do cursor podem ser puladas ou repetidas, dependendo de qual caiu na borda.

A correção é ordenar por uma chave garantidamente única — na prática, uma tupla que termina numa coluna única, como o id:

-- cursor = (criado_em, id) da última linha vista
SELECT * FROM pedidos
WHERE (criado_em, id) < ($1, $2)
ORDER BY criado_em DESC, id DESC
LIMIT 20;

O Postgres compara tuplas nativamente: (criado_em, id) < ($1, $2) significa "criado_em menor, ou criado_em igual e id menor". Isso desempata de forma determinística — nenhuma linha fica na fronteira ambígua. A ordenação do ORDER BY precisa espelhar exatamente a tupla, na mesma direção.

Regra do keyset: a chave de ordenação tem que ser única e estável. Se a coluna que você ordena pode repetir, anexe uma coluna única (o id) e compare a tupla inteira. Sem isso, a paginação parece funcionar nos testes e perde/duplica linhas exatamente nos empates.

O índice que sustenta tudo

Cursor sem o índice certo é só um WHERE lento. A tupla do ORDER BY precisa de um índice composto na mesma ordem e direção:

CREATE INDEX idx_pedidos_keyset
  ON pedidos (criado_em DESC, id DESC);

Com ele, a comparação de tupla vira um Index Scan que salta direto para o ponto de corte e lê 20 linhas já ordenadas — sem etapa de Sort, sem linhas descartadas. É a diferença entre a query custar o tamanho da página e custar o tamanho de tudo que veio antes dela. (Se a ordem das colunas no índice te parece arbitrária, ela não é — é o mesmo assunto do artigo sobre índices que o Postgres ignora.)

Empacotando o cursor para a API

Expor criado_em e id crus na URL vaza detalhe interno e convida o cliente a construir cursores na mão. O padrão é devolver um cursor opaco — os valores codificados num token que o cliente só repassa, sem interpretar:

// gerar o cursor a partir da última linha da página
const cursor = Buffer
  .from(JSON.stringify([ultima.criado_em, ultima.id]))
  .toString('base64url');

// resposta da API
{
  "dados": [ /* 20 pedidos */ ],
  "proximo_cursor": "WyIyMDI2LTA3LTI5..."  // opaco para o cliente
}

// próxima página: cliente manda ?cursor=WyIyMDI2...
const [criadoEm, id] = JSON.parse(
  Buffer.from(req.query.cursor, 'base64url').toString()
);

O cliente trata o cursor como um ponteiro cego: recebeu, devolve para pedir a próxima página. Você fica livre para mudar as colunas de ordenação por dentro sem quebrar quem consome — o token é um contrato opaco, não um formato público.

O que você perde com cursor

Keyset não é gratuito nem universal. Ele troca capacidades por performance, e às vezes você precisa das capacidades:

RecursoOFFSETCursor
Custo da página 500 500× a página 1 Igual à página 1
Estável sob inserção Não (pula/duplica) Sim
Pular para a página 47 Sim, trivial Não — só próxima/anterior
"Página X de Y" Sim Não sem um count à parte
Complexidade Baixa Média (tupla, cursor, índice)

A limitação decisiva é o acesso aleatório: cursor navega em sequência — próxima, próxima, anterior — mas não "vá para a página 47", porque ele não sabe o valor de corte da página 47 sem percorrer até lá. Também não te dá um total de páginas de graça.

Quando OFFSET ainda é a escolha certa

O ponto não é banir OFFSET — é usá-lo onde ele não dói:

A régua: feed, scroll infinito, "carregar mais", export e qualquer API pública sobre tabela que cresce pedem cursor. Tabela administrativa pequena com número de página fica bem com OFFSET.

Em resumo

OFFSET não pula linhas — ele as lê e joga fora, e por isso a página 500 degrada mesmo com LIMIT constante. Pior: como pagina por posição numa lista que muda, ele pula e duplica itens sob concorrência.

Paginação por cursor filtra pela chave de ordenação em vez de pular, usa o índice para saltar direto ao ponto e custa o mesmo em qualquer profundidade — desde que a chave seja única (a tupla (criado_em, id)) e tenha um índice na mesma direção por trás. Você abre mão de "ir para a página 47" e ganha uma API que não degrada. Para feed e scroll infinito, é troca fácil de fazer.