Por que toda coleção deve ser paginada
Um endpoint de lista sem limite é uma promessa que você não conseguirá cumprir. O código pode estar correto no dia do lançamento, quando a tabela possui algumas centenas de linhas, e tornar-se silenciosamente um problema à medida que a tabela cresce. Um SELECT * em uma tabela de um milhão de linhas irá serializar dezenas de megabytes, mantê-los na memória e transmiti-los para um cliente que provavelmente queria apenas os primeiros vinte itens.
A falha não é gradual. Em determinado tamanho de tabela, o endpoint cruza um limite: a memória dispara, o event loop trava, as requisições sofrem timeout, as tentativas de reenvio se acumulam e uma única rota lenta derruba todo o serviço. Um atacante não autenticado nem sequer precisa de um bug, apenas de uma URL.
A solução é estabelecer um limite rígido para cada coleção:
- Um tamanho de página padrão para que os clientes recebam uma resposta útil sem precisar configurar nada.
- Um tamanho de página máximo para que nenhum cliente consiga solicitar todos os dados.
- Uma ordenação estável para que as páginas não se sobreponham nem pulem itens.
- Um token de continuação para que o cliente possa solicitar a próxima fatia de dados.
Mesmo endpoints que você acredita serem pequenos merecem um limite. Tabelas crescem, importações acontecem, e o endpoint que você protege hoje é aquele que permanecerá online amanhã.
Paginação por offset e onde ela falha
A forma mais familiar é LIMIT com OFFSET: retorne limit linhas, começando após offset linhas.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
É fácil de entender, mapeia naturalmente para números de página (page * limit) e permite que o cliente pule para qualquer página. Para tabelas pequenas e que mudam lentamente, é perfeitamente adequada. Para todo o resto, ela apresenta três problemas que pioram conforme os dados crescem.
Páginas profundas são caras. Para retornar as linhas após um offset, o banco de dados deve primeiro produzir e descartar cada linha anterior. OFFSET 1000000 lê um milhão de linhas para retornar vinte. O trabalho cresce linearmente com o número da página, portanto, a página 1 é rápida e a página 50.000 não é.
A janela desliza. O offset é uma posição em uma lista que está mudando. Se uma linha for inserida antes da sua posição, a próxima página repetirá um item; se uma linha for deletada, um item será pulado. O cliente vê duplicatas e lacunas, e nenhuma tentativa de repetição resolve isso.
A ordem pode ser instável. Se a chave de ordenação tiver empates, como created_at frequentemente tem, o banco de dados fica livre para retornar as linhas empatadas em qualquer ordem. Duas requisições para o mesmo offset podem produzir resultados diferentes.
O offset não está errado, ele apenas não é adequado para coleções grandes ou que mudam rapidamente. Use-o para tabelas administrativas com números de página sobre dados modestos, e utilize um cursor quando a lista puder crescer.
Paginação por cursor: estável por construção
Um cursor substitui o “pule N linhas” por “comece após esta linha”. O cliente envia um token opaco produzido pelo servidor, e o servidor o converte de volta em uma posição precisa na ordenação.
Como o token identifica uma linha em vez de uma contagem, inserções e exclusões em outras partes do conjunto de resultados não podem deslocá-lo. Como o servidor busca diretamente aquela linha, a profundidade não gera custo extra. As duas garantias — estabilidade e custo constante — são exatamente as que o offset não consegue fornecer.
O preço é que um cursor só se move para frente ou para trás. Não existe a “página 50”. Para feeds, timelines, exportações e scroll infinito, isso não representa perda alguma. Para uma grade de resultados de busca com páginas numeradas, o offset continua sendo a escolha natural até que o volume de dados se torne muito grande.
Um cursor deve ser opaco para os clientes: codificado em base64url, tratado como uma caixa preta e retornado sem alterações. A opacidade permite que o servidor altere as colunas de ordenação posteriormente sem quebrar os clientes, além de desencorajar a criação de tokens manuais.
Paginação por Keyset em SQL
A paginação por keyset é o que um cursor faz no nível do banco de dados. Em vez de contar, você compara as colunas de ordenação com os valores da última linha retornada.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
A forma (created_at, id) < ($1, $2) é uma comparação de valor de linha (row-value comparison) e expressa a lógica de maneira limpa: pegue as linhas cuja chave de ordenação venha estritamente após a última. O Postgres e a maioria dos bancos de dados relacionais podem usar um índice que corresponda à ordenação para buscar diretamente o ponto de partida.
Esse índice não é opcional. A paginação por keyset só é rápida quando a ordenação é suportada por um índice sobre as colunas e direções exatas utilizadas em ORDER BY.
CREATE INDEX posts_created_id_idx
ON posts (created_at DESC, id DESC);
Dois detalhes causam a maioria dos bugs. Primeiro, a direção da comparação deve corresponder à ordenação: uma ordenação DESC usa <, e uma ordenação ASC usa >. Segundo, o cursor deve incluir todas as colunas de ordenação. Se você ordenar apenas por created_at, empates tornam o cursor ambíguo, e é por isso que id é sempre anexado.
Construindo e codificando um cursor
Um cursor são apenas os valores de ordenação da última linha da página, serializados e codificados. Mantenha-o pequeno e valide-o ao recebê-lo de volta.
export type Cursor = { createdAt: string; id: string };
export function encodeCursor(cursor: Cursor): string {
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
}
export function decodeCursor(token: string): Cursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
} catch {
throw new Error("invalid_cursor");
}
const value = parsed as Record<string, unknown>;
if (typeof value.createdAt !== "string" || typeof value.id !== "string") {
throw new Error("invalid_cursor");
}
return { createdAt: value.createdAt, id: value.id };
}
O truque do “limite mais um” informa se existe outra página sem a necessidade de um count. Busque limit + 1 linhas; se você receber mais do que limit, existe uma próxima página, e você descarta a linha extra antes de responder.
const rows = await db.query(
`SELECT id, title, created_at
FROM posts
WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1, $2))
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1],
);
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const last = data.at(-1);
const nextCursor = hasMore && last
? encodeCursor({ createdAt: last.created_at, id: last.id })
: null;
Base64url é uma codificação, não uma assinatura. Um cliente pode decodificá-la e criar uma nova, portanto, nunca trate um cursor como uma entrada confiável. Valide cada campo e, se a adulteração for um problema, assine o payload com um HMAC ou mantenha as chaves de ordenação no servidor e armazene o cursor no Redis.
O envelope de resposta paginado
Retorne um formato consistente em todos os endpoints de coleção. Assim, os clientes terão um único padrão para analisar e você poderá evoluir a implementação interna sem alterar o contrato.
{
"data": [
{ "id": "post_1042", "title": "Hello", "createdAt": "2026-09-16T10:00:00Z" }
],
"pagination": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTE2VDEwOjAwOjAwWiIsImlkIjoicG9zdF8xMDQyIn0",
"hasMore": true,
"total": 1284
}
}
data contém a página e nunca excede o limite. nextCursor é o token para a página seguinte, ou null quando a lista termina, o que serve como sinal para o infinite scroll parar. hasMore é uma conveniência que evita que os clientes precisem verificar valores nulos, e é obtido automaticamente através da busca de limite mais um (limit-plus-one).
total é opcional e deve ser tratado como tal. Uma página baseada em cursor não precisa disso, e calculá-lo em cada requisição costuma ser mais caro do que a própria página. Inclua-o apenas quando a UI realmente exibir “1.284 resultados” e, nesse caso, faça o cache ou use uma aproximação.
Estabilidade de ordenação e desempate
Um cursor só é útil se a ordenação for uma ordem total: para quaisquer duas linhas, uma deve estar definitivamente antes da outra. A maioria das chaves de ordenação naturais não é assim. Muitos posts compartilham o mesmo created_at, e o banco de dados pode retorná-los em qualquer ordem; portanto, um cursor que codifica apenas o timestamp pode pular ou repetir linhas.
A solução é adicionar uma coluna única, quase sempre a chave primária, como a chave de ordenação final.
ORDER BY created_at DESC, id DESC
Agora a ordem é determinística e o cursor (created_at, id) é único. A mesma regra se aplica a qualquer ordenação: ORDER BY score DESC, id DESC, ORDER BY name ASC, id ASC. O desempate deve ser único e deve fazer parte tanto do índice quanto do cursor.
As chaves de ordenação também devem ser estáveis ao longo do tempo. Ordenar por updated_at e utilizá-lo em um cursor é uma armadilha: quando uma linha é editada, sua posição na ordenação muda, e um cursor capturado antes da edição pode apontar para o lugar errado. Prefira chaves imutáveis, como created_at ou um id monotônico; se você precisar ordenar por um campo mutável, aceite que os cursores podem se tornar obsoletos.
Contagens totais são caras
A contagem total é a parte mais solicitada e menos necessária da paginação. SELECT count(*) com um filtro deve examinar cada linha correspondente e, em uma tabela grande, isso pode levar mais tempo do que a busca da própria página.
-- Runs on every request if you are not careful.
SELECT count(*) FROM posts WHERE author_id = $1;
Uma página baseada em cursor não precisa disso. hasMore responde à pergunta que o cliente realmente tem — “existe mais?” — sem tocar em nenhuma linha extra. Se a UI realmente precisar de um número, escolha uma abordagem que se adapte à precisão tolerável:
- Omita-o. A maioria das interfaces de feed e scroll infinito nunca mostra um total.
- Aproxime-o. O Postgres expõe
reltuplesempg_classe o planner pode estimar comEXPLAIN; ambos são rápidos e erram por alguns poucos percentuais. - Faça cache. Calcule a contagem em um cronograma ou após as escritas e sirva o valor armazenado.
- Mantenha um contador. Mantenha uma contagem atualizada em uma tabela de resumo, atualizada dentro da mesma transação das escritas.
- Limite-o. Pare de contar em 1.000 e retorne “1000+”, o que limita o custo.
Seja qual for a sua escolha, não execute um count(*) sem cache em cada requisição de página de uma tabela grande.
Tamanho da página: padrões e limites
Dois números protegem o servidor: um valor padrão para quando o cliente não especifica nada, e um limite máximo rigoroso para quando o cliente solicita demais.
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
function parseLimit(raw: string | undefined): number {
const requested = Number.parseInt(raw ?? "", 10);
if (!Number.isFinite(requested) || requested < 1) return DEFAULT_LIMIT;
return Math.min(requested, MAX_LIMIT);
}
Prefira limitar (clamp) a rejeitar. Um cliente que solicita limit=1000 deve receber 100 linhas e um cursor, e não um erro 400 que o force a adivinhar as suas regras. Valide se limit é um número inteiro positivo e nunca passe uma string do cliente diretamente para o SQL.
O tamanho da página é um ajuste de latência. Páginas maiores significam menos round-trips, mas mais processamento por requisição e mais bytes trafegando na rede. Para UIs interativas, entre 20 e 50 costuma ser o ideal. Para exportações em massa, utilize um endpoint dedicado com um limite muito maior e streaming, em vez de aumentar o limite na rota interativa.
Parâmetros de filtragem e ordenação
A paginação compõe-se com a filtragem e a ordenação, mas ambas devem ser tratadas com cuidado porque alteram o significado de um cursor.
GET /posts?author_id=42&sort=-created_at&limit=20&cursor=eyJjcmVhdGVkQXQiOi...
Crie uma whitelist de campos de ordenação. Nunca interpole o nome de uma coluna fornecido pelo cliente diretamente no SQL. Mapeie um conjunto permitido de nomes para expressões e defina a direção a partir de um prefixo ou parâmetro explícito.
const SORTS = {
created_at: "created_at",
title: "title",
score: "score",
} as const;
const column = SORTS[sortField] ?? "created_at";
const direction = order === "asc" ? "ASC" : "DESC";
Faça com que o cursor corresponda à ordenação. Se a ordenação mudar, o cursor perde o sentido. Ou codifique a ordenação no cursor e rejeite qualquer incompatibilidade, ou inclua a ordenação no payload do cursor e a verifique na decodificação. O mesmo vale para os filtros: um cursor de uma lista não filtrada não deve ser reutilizado em uma lista filtrada.
Adicione cada coluna de ordenação ao índice. Um índice composto em (author_id, created_at DESC, id DESC) atende ao filtro e à busca de keyset em uma única estrutura, o que é a diferença entre carregar uma página em um milissegundo ou em um segundo.
Paginação em buscas e agregações
Mecanismos de busca e agregações possuem suas próprias regras. Backends de busca textual geralmente limitam from + size a cerca de dez mil resultados, pois o offset profundo também é caro para eles. O equivalente ao keyset nesse cenário é um token search_after, construído a partir dos valores de ordenação do último resultado.
POST /posts/_search
{
"size": 20,
"sort": [{ "created_at": "desc" }, { "id": "desc" }],
"search_after": ["2026-09-16T10:00:00Z", "post_1042"]
}
O ideal é que as agregações sejam retornadas separadamente da página. Calcular a contagem de facets para cada correspondência em cada requisição é a mesma armadilha que count(*). Ou você as calcula uma vez e as armazena em cache, ou expõe um endpoint dedicado que a UI chama quando o usuário abre um painel de filtros.
Para agregados SQL, a mesma ideia de keyset se aplica: ordene por uma chave de agregação estável, como um bucket de data ou um id, e use essa chave no cursor. Não pagine um GROUP BY com OFFSET em uma tabela grande; materialize o agregado primeiro e pagine o resultado materializado.
Escolhendo uma estratégia
A maioria das equipes precisa de apenas uma regra: se a coleção puder crescer muito ou mudar enquanto está sendo lida, use um cursor; se ela for pequena, tiver poucas alterações e for exibida como uma grade numerada, o offset é suficiente.
Small table, numbered UI -> offset
Large or fast-changing collection -> keyset cursor
Infinite scroll or mobile feed -> keyset cursor
Search results -> search_after token
Bulk export -> dedicated streaming endpoint
Offset e cursor podem coexistir. Uma tabela de administração pode oferecer números de página para navegação e um cursor para um fluxo de “exportar tudo”. O importante é que cada endpoint escolha um estilo e o documente, em vez de misturar parâmetros de page e cursor de uma forma que os clientes não consigam prever.
Paginação reversa
A paginação para frente recebe toda a atenção, mas muitas interfaces também precisam de um botão “anterior”. A técnica consiste em inverter a comparação e a ordenação, buscar uma página e, em seguida, inverter as linhas no código da aplicação antes de retorná-las.
async function pageBackward(prev: Cursor, limit: number) {
const rows = await db.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT $3`,
[prev.createdAt, prev.id, limit + 1],
);
// Reverse back into the canonical descending order.
return rows.reverse();
}
Retorne um prevCursor construído a partir da primeira linha da página atual, juntamente com nextCursor construído a partir da última. Um cliente navegando para trás alterna para a direção para frente quando o usuário volta a rolar para baixo, portanto, os cursores devem ser intercambiáveis em vez de estarem vinculados a uma direção.
Mantendo a integridade dos cursores
Base64url é uma codificação, não uma assinatura. Um cliente pode decodificar um cursor, editá-lo e enviá-lo de volta; portanto, um cursor é um input não confiável, exatamente como um parâmetro de query. Valide cada campo na decodificação e rejeite qualquer coisa que não corresponda ao formato esperado antes que chegue ao SQL.
Se a manipulação de dados for uma preocupação real — por exemplo, se um cursor carregar um tenant id — assine-o com um HMAC e verifique a assinatura em tempo constante.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.CURSOR_SECRET!;
export function signCursor(payload: string): string {
const mac = createHmac("sha256", secret).update(payload).digest("base64url");
return `${Buffer.from(payload).toString("base64url")}.${mac}`;
}
export function verifyCursor(token: string): string {
const [encoded, mac] = token.split(".");
const expected = createHmac("sha256", secret).update(encoded).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error("invalid_cursor");
}
return Buffer.from(encoded, "base64url").toString("utf8");
}
Uma alternativa é manter os cursores totalmente no server-side: armazene a posição no Redis sob um id aleatório e entregue ao cliente apenas esse id. Isso oculta completamente as colunas de ordenação e permite a expiração, ao custo de um lookup a cada página.
Um cliente que segue cursores
Um cursor é projetado para ser seguido, portanto o código do cliente é um loop simples: solicita uma página, anexa os dados e continua enquanto nextCursor não for nulo. Não há aritmética de páginas nem risco de pular alguma página.
async function fetchAll<T>(path: string): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
do {
const url = new URL(path, "https://api.example.com");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(`request_failed_${res.status}`);
const page = await res.json();
items.push(...page.data);
cursor = page.pagination.nextCursor;
} while (cursor);
return items;
}
O loop termina em nextCursor === null, e é por isso que esse campo deve ser definido de forma confiável na página final. Para scroll infinito, o mesmo padrão é executado página por página conforme um elemento sentinela entra no viewport, e o token é mantido no estado do componente em vez de na URL.
Paginação e o plano de consulta
A paginação por keyset é rápida apenas quando o banco de dados consegue utilizar um índice. Sempre confirme isso com EXPLAIN ANALYZE em vez de presumir.
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-09-16T10:00:00Z', 'post_1042')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Limit (cost=0.43..8.94 rows=20 width=40)
(actual time=0.021..0.058 rows=20 loops=1)
-> Index Scan Backward using posts_created_id_idx on posts
(cost=0.43..521.10 rows=417 width=40)
(actual time=0.019..0.051 rows=20 loops=1)
Index Cond: (ROW(created_at, id) < ROW('2026-09-16T10:00:00Z'::timestamptz, 'post_1042'))
Execution Time: 0.081 ms
O Index Scan Backward com um Index Cond é o resultado esperado: o banco de dados busca a posição do cursor e para após vinte linhas. Um Seq Scan com um Filter significa que o índice não corresponde à ordenação, e a consulta está escaneando a tabela inteira a cada página. O índice deve listar as mesmas colunas na mesma ordem e direção que ORDER BY, com o critério de desempate por último.
Testando a paginação
As propriedades que valem a pena testar são a estabilidade e a terminação, e não apenas o “caminho feliz”. Um teste que percorre todas as páginas e verifica se não há duplicatas nem linhas ausentes captura bugs sutis de desempate (tie-breaker) que, de outra forma, seriam invisíveis.
test("cursor pagination never repeats or skips rows", async () => {
const seen = new Set<string>();
let cursor: string | null = null;
do {
const page = await request(app)
.get("/posts")
.query({ limit: 10, cursor: cursor ?? undefined })
.expect(200);
for (const post of page.body.data) {
expect(seen.has(post.id)).toBe(false);
seen.add(post.id);
}
cursor = page.body.pagination.nextCursor;
} while (cursor);
expect(seen.size).toBe(totalPosts);
});
test("rejects a malformed cursor", async () => {
await request(app).get("/posts?cursor=not-a-cursor").expect(400);
});
Teste também os limites: a primeira página sem cursor, a última página onde nextCursor é nulo, uma página maior que o máximo e uma ordenação que muda no meio do percurso. Este último é o teste que prova que o seu critério de desempate funciona.
Melhores práticas
- Defina um limite padrão e um máximo rigoroso para cada coleção, e utilize o clamp (limitação) em vez de rejeitar a requisição.
- Prefira paginação por keyset ou cursor para qualquer dado que cresça ou mude.
- Sempre ordene com um critério de desempate único, como
id, e inclua-o no índice e no cursor. - Mantenha os cursores opacos, codifique-os como base64url e valide cada campo na decodificação.
- Busque
limit + 1para determinarhasMoreem vez de executar um count. - Retorne um envelope consistente com
data,nextCursorehasMoreem todos os lugares. - Trate
totalcomo opcional; omita, aproxime ou faça o cache. - Utilize uma whitelist para campos e direções de ordenação, e vincule todos os valores como parâmetros.
- Faça com que o cursor codifique o contexto de ordenação e filtro, para que um token expirado não possa ser reutilizado.
Erros comuns
- Lançar um endpoint de listagem sem limite e descobrir o problema quando a aplicação escala.
- Usar
OFFSETpara páginas profundas e observar a latência crescer conforme o número da página aumenta. - Ordenar por uma coluna não única sem um critério de desempate, fazendo com que linhas se repitam ou desapareçam.
- Ordenar por uma coluna mutável e tratar o cursor como permanente.
- Passar um
limit, nome de coluna ou direção fornecido pelo cliente diretamente para o SQL. - Executar um
count(*)sem cache em cada requisição de página. - Expor IDs internos brutos ou timestamps em um cursor e dizer que é seguro.
- Retornar apenas um array sem cursor, forçando os clientes a adivinharem como continuar.
- Permitir um
limitde um milhão porque a UI nunca solicita isso.
Próximos passos
A paginação faz parte do design de uma API previsível, por isso o guia de REST é o complemento ideal para entender formatos de recursos, códigos de status e convenções de query. Se você quiser evitar o reprocessamento da mesma página, o guia de Caching aborda como servi-la a partir da memória, e o de Connection Pooling garante que cada query paginada tenha um custo baixo para o banco de dados. Para tornar essas queries rápidas desde o início, o guia de PostgreSQL explica os índices compostos e planos de query nos quais a paginação por keyset se baseia.