Paginação por cursor: sem item repetido e sem OFFSET
Paginação por cursor testada em 300 mil linhas: por que o OFFSET repete item, quanto custa a página funda e a armadilha da comparação de linha no MySQL 8.4.
Paginação com LIMIT e OFFSET parece certa em todo teste feito com a tabela parada. O problema só aparece quando alguém insere ou apaga uma linha entre uma página e outra, e aí a lista repete ou esconde itens sem erro nenhum na tela. Em vez de explicar isso em abstrato, este guia mostra o que acontece numa tabela de 300 mil pedidos no PostgreSQL 18.6 e no MySQL 8.4.13, e como a paginação por cursor (também chamada de keyset) resolve as duas coisas: a lista que mente e a página funda que fica lenta.
O teste: 300 mil pedidos e uma inserção no meio
A tabela tem três pedidos por minuto, de propósito: datas repetidas são o caso real de qualquer sistema com movimento, e é nelas que paginação mal feita quebra.
CREATE TABLE pedidos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cliente text NOT NULL,
criado_em timestamptz NOT NULL
);
INSERT INTO pedidos (cliente, criado_em)
SELECT 'cliente ' || g, timestamptz '2026-01-01' + (g / 3) * interval '1 minute'
FROM generate_series(1, 300000) g; -- 3 pedidos por minuto: datas repetidas de propósito
CREATE INDEX pedidos_criado_id ON pedidos (criado_em DESC, id DESC);
-- página 1
SELECT id FROM pedidos ORDER BY criado_em DESC, id DESC LIMIT 5;
-- alguém cria um pedido enquanto a pessoa lê
INSERT INTO pedidos (cliente, criado_em) VALUES ('novo', now());
-- página 2 com OFFSET
SELECT id FROM pedidos ORDER BY criado_em DESC, id DESC LIMIT 5 OFFSET 5;
página 1: 300000, 299999, 299998, 299997, 299996
página 2 com OFFSET: 299996, 299995, 299994, 299993, 299992
O pedido 299996, último da página 1, abriu a página 2. OFFSET 5 quer dizer "descarte as 5 primeiras linhas da ordem atual", e a ordem atual ganhou uma linha no topo. Se em vez de inserir alguém tivesse cancelado um pedido da página 1, o efeito seria o contrário: um item subiria para a página já vista e a pessoa nunca o encontraria. A documentação do PostgreSQL sobre LIMIT e OFFSET lembra ainda outro ponto: sem um ORDER BY que dê uma ordem única, as páginas podem sair inconsistentes até com a tabela parada. Por isso o id entra como desempate em toda ordenação abaixo.
A mesma página 2 com cursor
Com cursor, a página seguinte não depende de posição, e sim do último item mostrado. A consulta pede "os pedidos que vêm depois deste na ordem escolhida":
-- página 2 por cursor: "os 5 que vêm depois do último que a pessoa viu"
SELECT id FROM pedidos
WHERE (criado_em, id) < ('2026-03-11 10:38:00+00', 299996)
ORDER BY criado_em DESC, id DESC
LIMIT 5;
página 2 por cursor: 299995, 299994, 299993, 299992, 299991
O pedido novo no topo não altera nada, porque a referência é o par (data, id) do 299996. A comparação (criado_em, id) < (...) é uma comparação de linha: compara a data e, se ela empatar, o id. Sem o id no cursor, os outros pedidos do mesmo minuto seriam pulados.
Quanto custa uma página funda
O segundo ganho é de desempenho. Para entregar 20 linhas depois de pular 250 mil, o banco precisa percorrer as 250 mil:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM pedidos ORDER BY criado_em DESC, id DESC LIMIT 20 OFFSET 250000;
Limit (actual time=39.440..39.444 rows=20.00 loops=1)
-> Index Only Scan using pedidos_criado_id on pedidos
(actual time=0.008..30.336 rows=250020.00 loops=1)
Execution Time: 39.460 ms
Com cursor apontando para o mesmo lugar da lista, o índice vai direto ao ponto:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM pedidos
WHERE (criado_em, id) < ('2026-01-12 13:47:00+00', 50002)
ORDER BY criado_em DESC, id DESC LIMIT 20;
Limit (actual time=0.044..0.047 rows=20.00 loops=1)
-> Index Only Scan using pedidos_criado_id on pedidos
(actual time=0.044..0.045 rows=20.00 loops=1)
Index Cond: (ROW(criado_em, id) < ROW('2026-01-12 13:47:00+00'::timestamp with time zone, 50002))
Execution Time: 0.063 ms
São 250.020 linhas lidas contra 20, e 39 ms contra 0,06 ms. Numa página só, 39 ms parecem pouco. Num robô que exporta a tabela de 100 em 100, ou numa API pública em que alguém pagina até o fim, o custo do
OFFSET cresce a cada página, e o do cursor fica constante.
No MySQL, a comparação de linha engana
O MySQL aceita a mesma sintaxe (criado_em, id) < (?, ?) e devolve o resultado certo. Só que no teste com MySQL 8.4.13, mesma tabela e mesmo índice composto, o plano mostrou outra história:
-- forma de linha: o MySQL 8.4.13 filtra DEPOIS de varrer o índice
-> Filter: ((pedidos.criado_em,pedidos.id) < ('2026-01-12 13:47:00',50002))
-> Covering index scan on pedidos using pedidos_criado_id (reverse)
(actual time=0.0135..32.7 rows=250019 loops=1)
-- forma expandida: vira busca por faixa no índice
-> Covering index range scan on pedidos using pedidos_criado_id over
(criado_em < '2026-01-12 13:47:00.000000') OR
(criado_em = '2026-01-12 13:47:00.000000' AND id < 50002) (reverse)
(actual time=0.105..0.17 rows=20 loops=1)
Com a comparação de linha, ele varreu 250 mil entradas do índice e filtrou depois, o mesmo custo do OFFSET. Com a forma expandida, fez busca por faixa e leu 20. Então, no MySQL, escreva assim e confira o seu caso com EXPLAIN ANALYZE:
SELECT id, cliente, criado_em FROM pedidos WHERE criado_em < ? OR (criado_em = ? AND id < ?) ORDER BY criado_em DESC, id DESC LIMIT 21;
Os três parâmetros são a data do último item, a mesma data de novo e o id dele.
Voltar uma página
Cursor anda para a frente com naturalidade. Para voltar, inverta a comparação e a ordem, pegue os itens anteriores ao primeiro da página atual e desinverta o resultado antes de mostrar:
-- página anterior: inverta a comparação e a ordem, depois desinverta SELECT * FROM ( SELECT id, cliente, criado_em FROM pedidos WHERE (criado_em, id) > ($1, $2) -- primeiro item da página atual ORDER BY criado_em ASC, id ASC LIMIT 21 ) anterior ORDER BY criado_em DESC, id DESC;
O que o cursor não faz é pular para "página 37". Se a tela precisa de números de página, ou a lista é pequena e quase parada (um relatório fechado, uma tela administrativa com algumas centenas de linhas), OFFSET com desempate pelo id continua sendo a escolha simples. Para filtrar e limitar a consulta em si, o guia de SELECT com LIMIT, filtro e índice cobre o básico.
O cursor que vai para o cliente
Na API, o cursor vira um texto opaco que o cliente devolve na próxima chamada. Este módulo usa o driver pg 8.23.1 e foi testado contra a tabela acima:
const { Pool } = require('pg');
const pool = new Pool(); // PGHOST, PGUSER, PGPASSWORD e PGDATABASE vêm do ambiente
const ORDENS = { recentes: 'recentes' }; // cada ordenação aceita tem nome próprio
function criarCursor(item, ordem) {
const obj = { o: ordem, c: item.cursor_criado, i: item.id };
return Buffer.from(JSON.stringify(obj)).toString('base64url');
}
function lerCursor(texto, ordem) {
try {
const obj = JSON.parse(Buffer.from(texto, 'base64url').toString('utf8'));
if (obj.o !== ordem || typeof obj.c !== 'string' || !/^\d+$/.test(obj.i)) return null;
if (Number.isNaN(Date.parse(obj.c))) return null;
return obj;
} catch {
return null;
}
}
async function listarPedidos(textoCursor, tamanho = 20) {
const params = [];
let sql = 'SELECT id, cliente, criado_em, criado_em::text AS cursor_criado FROM pedidos';
if (textoCursor) {
const cur = lerCursor(textoCursor, ORDENS.recentes);
if (!cur) { const e = new Error('cursor inválido'); e.status = 400; throw e; }
params.push(cur.c, cur.i);
sql += ' WHERE (criado_em, id) < ($1::timestamptz, $2::bigint)';
}
params.push(tamanho + 1);
sql += ' ORDER BY criado_em DESC, id DESC LIMIT $' + params.length;
const { rows } = await pool.query(sql, params);
const temMais = rows.length > tamanho;
const itens = temMais ? rows.slice(0, tamanho) : rows;
return { itens, proximo: temMais ? criarCursor(itens.at(-1), ORDENS.recentes) : null };
}
module.exports = { listarPedidos, pool };
const { listarPedidos, pool } = require('./cursor');
(async () => {
const p1 = await listarPedidos(null, 3);
console.log('página 1:', p1.itens.map((r) => r.id).join(','), 'cursor:', p1.proximo);
const p2 = await listarPedidos(p1.proximo, 3);
console.log('página 2:', p2.itens.map((r) => r.id).join(','));
try { await listarPedidos('bGl4bw', 3); } catch (e) { console.log('cursor adulterado ->', e.status, e.message); }
await pool.end();
})();
Para o teste, metade dos pedidos ganhou 123456 microssegundos na data. A saída:
página 1: 300001,300000,299998 cursor: eyJvIjoicmVjZW50ZXMiLCJjIjoiMjAyNi0wMy0xMSAxMDozOTowMC4xMjM0NTYrMDAiLCJpIjoiMjk5OTk4In0 página 2: 299999,299997,299996 cursor adulterado -> 400 cursor inválido
Três detalhes do código fazem diferença:
- A data vai como texto do próprio banco (
criado_em::text). Otimestamptzguarda microssegundos e oDatedo JavaScript guarda milissegundos. O cursor acima carrega10:39:00.123456; montado a partir doDate, viraria.123e a próxima página poderia repetir ou pular pedidos do mesmo instante. - O id fica como string. O
pgdevolvebigintcomo texto porque ele pode passar do maior inteiro seguro do JavaScript. - O cursor registra a ordenação. Se a tela permite ordenar por valor ou por cliente, cada opção tem as suas colunas (sempre com o id no fim) e o seu índice, e um cursor de uma ordenação não pode ser aceito em outra. Cursor que não confere responde 400, em vez de voltar em silêncio para a primeira página.
Base64url não é criptografia: qualquer pessoa decodifica o cursor. Não coloque nele nada que o usuário não possa ver, e trate o conteúdo como entrada externa, que é o que ele é. Evite também ordenar por coluna que aceita NULL, porque NULL não é maior nem menor que nada e a comparação passa a precisar de casos especiais.
Na tela: rolagem infinita sem pular item
O cursor combina com rolagem infinita: o front guarda o proximo da última resposta e pede a página seguinte quando um elemento sentinela no fim da lista entra na tela. A parte de detectar esse momento, com rootMargin para carregar antes de a pessoa chegar ao fim, está no guia de IntersectionObserver. E para que a lista infinita não esconda o rodapé, veja rolagem infinita sem prender o rodapé.
Pedir 21 itens para mostrar 20, como nas consultas acima, também evita o COUNT(*) a cada página: se veio o 21º, existe próxima. Contar a tabela inteira só para habilitar um botão costuma ser a consulta mais cara da tela.
Referência: https://www.postgresql.org/docs/current/queries-limit.html