Paginação com base em cursor versus paginação por offset — Por que page=2 exclui silenciosamente registros
A paginação com offset tem um bug reproduzível: linhas novas inseridas entre as requisições de página ignoram silenciosamente registros. Aqui está o SQL que prova isso, e quando usar paginação com cursor em vez disso.
Você está construindo um feed. A página 1 carrega, o usuário rola para baixo e você recarrega a página 2. Mas alguns itens que existiam quando a página 1 foi carregada não aparecem na página 2. Não há erro, nem aviso — eles simplesmente não aparecem.
Este é o bug de paginação por offset e não é uma condição de corrida. É determinístico. Todo sistema que use LIMIT x OFFSET y um banco de dados em tempo real e com grande volume de escrita acabará enfrentando esse problema.
O SQL que causa isso
A paginação por offset se traduz diretamente para:
-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;
-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
O banco de dados não tem memória do que estava na página 1 quando você solicita a página 2. Ele conta a partir da posição 0 sempre, contra as linhas que existem no momento.
A situação que quebra isso
Comece com 20 posts, IDs de 1 a 20, mais recentes primeiro. O usuário carrega a página 1 — recebe IDs de 20 a 11. Entre essa solicitação e a próxima, três novos posts são inseridos: IDs 21, 22 e 23.
Agora o usuário rola e recarrega a página 2:
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1
Os posts 10, 9 e 8 — que o usuário ainda não viu — foram simplesmente empurrados além da janela do offset. Três novas inserções causaram exatamente três registros omitidos. A matemática é sempre exata.
O usuário não vê nada errado. Seus logs mostram nada errado. Os registros existem no banco de dados. Eles simplesmente são invisíveis para aquela sessão.
A paginação por cursor corrige isso
Em vez de contar posições, você ancla a próxima página ao último registro realmente retornado. A consulta se torna "dê-me as próximas 10 linhas após essa linha específica" em vez de "dê-me as linhas de 11 a 20".
-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11
-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;
Novas inserções não afetam isso em absoluto. Elas aparecem no topo da ordem de classificação, antes da posição do cursor. Tudo a partir do cursor é estável, independentemente de escritas concorrentes.
O que é realmente um cursor
Um cursor é a posição codificada do último registro retornado. Na prática, geralmente é um dos seguintes:
- Um par de timestamp + ID codificado — a abordagem mais comum. Codifique
{"created_at": "...", "id": 11}como uma string base64 e retorne comonext_cursor. - Um UUID ou um ID de linha abstrato — alguns sistemas usam a chave primária diretamente quando a ordem é temporal (ULIDs, UUID v7). Um gerador de UUID é útil quando você está decidindo entre formatos de UUID para sua esquema — o v4 é aleatório e não ordena cronologicamente, o v7 é baseado em timestamp e sim.
- Um token assinado — a abordagem do Stripe, impedindo que os clientes fabricam valores de cursor que pulem para posições arbitrárias.
Quando a paginação por offset ainda é adequada
Não todo caso de uso precisa de cursos. A paginação por offset é adequada quando:
- O conjunto de dados é estático ou apenas acrescentado do final. Páginas de arquivamento, documentação, filas de exportação. Se nada for inserido no topo da ordem de classificação, o offset permanece estável.
- Dashboards de administração com números de página explícitos. Os usuários esperam pular para "página 5" e verem aquela fatia específica. Cursos não conseguem fazer isso — não há conceito de "página N" quando seu ancoramento é uma linha, não um contador.
- Dados pequenos sob seu controle. Paginando 200 registros em uma ferramenta interna onde os novos dados chegam por trabalhos diários? A complexidade dos cursos não vale a pena.
- As inserções só acontecem no final da ordem de classificação. Se sua paginação é em ordem crescente por ID e novos registros recebem IDs progressivamente maiores, as páginas anteriores não são afetadas por novas inserções.
A pergunta decisiva: podem novas linhas ser inseridas antes na posição atual do cursor entre as solicitações? Se sim, a paginação por offset silenciosamente omitirá registros. Se não, está tudo bem.
Comparação
| Paginação por offset | Paginação por cursor | |
|---|---|---|
| SQL | LIMIT x OFFSET y | WHERE (created_at, id) < (cursor) LIMIT x |
| Consistente em inserções concorrentes | Não — silenciosamente omite registros | Sim — a posição é estável |
| Pular para uma página arbitrária | Sim (OFFSET = page * size) | Não — apenas navegação para frente |
| Total de páginas | Fácil (COUNT(*) / size) | Não possível sem escaneamento completo |
| Desempenho em grandes offsets | Degrada — OFFSET 100000 escaneia 100k linhas para descartá-las | Estável — sempre usa um escaneamento por intervalo de índice |
| Complexidade no cliente | Baixa — apenas um número de página | Mais alta — deve armazenar e transmitir o token do cursor |
Como GitHub e Stripe lidam com isso
Ambos o API do GitHub ainda usa
GitHub
em a maioria dos endpoints, mas a API GraphQL usa paginação por cursor com page e per_page Esse cursor ( after e before:
query {
repository(owner: "vercel", name: "next.js") {
issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
pageInfo {
endCursor
hasNextPage
}
nodes {
title
number
}
}
}
}
) é codificado em base64 — decodifique-o e você obterá uma referência interna de linha, e não um número de página. Em um repositório como next.js, dezenas de issues podem ser criadas entre sua página 1 e página 2. Com paginação por offset, você silenciosamente omitirá algumas delas.Y3Vyc29yOnYyOpHOAABGPQ==Stripe's list APIs use
Stripe
— você passa o ID do último objeto recebido: starting_after e ending_before Os objetos do Stripe têm IDs ordenados no tempo (
# First page
curl https://api.stripe.com/v1/charges -u sk_test_xxx: -d limit=10
# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges -u sk_test_xxx: -d limit=10 -d starting_after=ch_1ABC123def456
), então o ID em si atua como o cursor. Sua API nunca ofereceu paginação por offset porque os dados de cobrança são exatamente onde os saltos silenciosos se tornam um problema sério — uma cobrança perdida não é apenas um problema de UX.ch_, cus_, pi_Implementando cursos: um padrão mínimo
Aqui está um exemplo mínimo de implementação em Node.js/PostgreSQL usando um cursor composto de timestamp + ID:
Uma coisa para ter cuidado: se você estiver usando UUIDs como chaves primárias em vez de inteiros auto-incrementados, o ordenamento composto só funciona se seus UUIDs estiverem ordenados no tempo. O UUID v4 é aleatório — ordenar por
function encodeCursor(row) {
return Buffer.from(JSON.stringify({
created_at: row.created_at,
id: row.id
})).toString('base64url');
}
function decodeCursor(cursor) {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}
async function fetchPage(db, cursor = null, limit = 10) {
let rows;
if (!cursor) {
rows = await db.query(
'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
[limit]
);
} else {
const { created_at, id } = decodeCursor(cursor);
// PostgreSQL supports tuple comparison natively.
// MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
rows = await db.query(
`SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[created_at, id, limit]
);
}
const nextCursor = rows.length === limit
? encodeCursor(rows[rows.length - 1])
: null;
return { rows, nextCursor };
}
funciona, mas você perde a garantia de empate que um ID sequencial oferece. Os UUID v7 ou ULIDs são ordenados no tempo e evitam esse problema completamente. (created_at, uuid_v4) A paginação por offset é simples de implementar e funciona bem até que seus dados sejam dinâmicos e com grande volume de escrita. O momento em que os usuários podem disparar inserções entre as solicitações de paginação — posts, cobranças, issues, qualquer coisa — a paginação por offset silenciosamente omite registros. O número de registros omitidos é exatamente igual ao número de novas inserções no topo da sua ordem de classificação. Sem erro, sem aviso.
A versão curta
A paginação por cursor troca "pular para página N" por estabilidade de posição. Para feeds visíveis e quaisquer APIs que outros desenvolvedores construam, isso é a troca correta. A paginação por offset é adequada para ferramentas de administração e exportações estáticas — não adicione complexidade de cursor onde o erro não pode ocorrer.
Paginação por Cursor versus Paginação por Offset — Por que a página 2 Silenciosamente Omite Registros 2
Você também pode gostar
Instale nossas extensões
Adicione ferramentas de IO ao seu navegador favorito para acesso instantâneo e pesquisa mais rápida
恵 O placar chegou!
Placar é uma forma divertida de acompanhar seus jogos, todos os dados são armazenados em seu navegador. Mais recursos serão lançados em breve!
Ferramentas essenciais
Ver tudo Novas chegadas
Ver tudoAtualizar: Nosso ferramenta mais recente Foi adicionado em 15 de junho de 2026
