API Design

Paginação

Todo endpoint de coleção precisa de um limite. A paginação transforma uma consulta ilimitada em algo previsível, e a escolha entre offset e cursor decide se ela continuará rápida.

intermediate14 min readUpdated 16 de set. de 2026
pagination.ts
ts
// pagination.ts
export type Page<T> = {
  data: T[];
  pagination: {
    nextCursor: string | null;
    hasMore: boolean;
    total?: number;
  };
};

export type Cursor = {
  createdAt: string;
  id: string;
};
Tamanho de página padrão
20 itens
Tamanho máximo de página
100 itens
Estilo recomendado
Keyset ou cursor
Formato do cursor
Opaque base64url
Desempate de ordenação
Sempre id
Contagem total
Opcional e custosa

Por que importa

Por que todo endpoint de lista precisa de um limite

Trabalho limitado por requisição

Um limite transforma uma consulta cujo custo cresce com a tabela em uma cujo custo cresce com a página, mantendo a latência estável conforme os dados acumulam.

Estabilidade sob escritas

Um cursor aponta para uma linha em vez de uma posição, portanto, inserções e exclusões em outras partes do conjunto de resultados não deslocam a janela de leitura.

Um contrato confiável para clientes

Um envelope consistente com data, nextCursor e hasMore permite que clientes web, mobile e de infinite-scroll compartilhem um padrão previsível.

O panorama completo

As três ideias por trás de uma página

Limite o trabalho, ordene-o de forma estável e entregue ao cliente um token que indique exatamente onde a próxima página começa.

Limites

Limit

Cada consulta carrega um tamanho de página, para que nenhuma requisição possa ler, serializar ou retornar mais linhas do que o servidor permite.

Ordenação

Stabilise

Uma ordenação total com um desempate único é o que torna um cursor significativo e evita linhas puladas ou duplicadas.

Cursor

Seek

Um predicado de keyset permite que o banco de dados busque diretamente a próxima linha através de um índice, em vez de contar as linhas puladas.

HTML5 de uma olhada

O que uma página deve responder

limit e offset

Solicita uma janela de linhas por posição. Simples, familiar e lento em páginas profundas.

Cursor

Um token opaco que codifica a última linha da página anterior.

Predicado de Keyset

WHERE (sort_key, id) < (?, ?) busca diretamente a próxima página via índice.

Ordenação estável

Sempre adicione uma coluna única, como id, como a chave de ordenação final.

Filtragem

Filtros restringem o conjunto antes da paginação, e o cursor deve respeitá-los.

hasMore

Busque o limite mais um para saber se existe outra página sem precisar contar.

Modelo de dados

O envelope de resposta paginado

Um formato para todo endpoint de coleção, quer o cliente use números de página, cursores ou infinite scroll.

O envelope de resposta paginadoJSON response
  • dataarrayA página de registros, nunca maior que o limite solicitado
  • pagination.nextCursorstring | nullToken opaco para ser enviado de volta como ?cursor= para a próxima página; null na última página
  • pagination.hasMorebooleanTrue quando existe pelo menos um registro além desta página; derivado da busca de limite-mais-um
  • pagination.totalnumber?Contagem total opcional, custosa em tabelas grandes, portanto omita-a ou sirva-a de um cache

Um formato para todo endpoint de coleção, quer o cliente use números de página, cursores ou infinite scroll.

Fluxo

Como uma página por cursor é servida

O cursor é decodificado, usado como um predicado de keyset e substituído por um novo token para a próxima página.

  1. 1

    Cliente envia limit e cursor

    A requisição carrega um tamanho de página e, para cada página após a primeira, o cursor opaco da resposta anterior.

  2. 2

    Servidor decodifica o cursor

    O Base64url é decodificado e validado. Um cursor malformado ou adulterado é rejeitado antes de chegar ao banco de dados.

  3. 3

    Consulta com WHERE de keyset

    Os valores de ordenação decodificados tornam-se uma comparação de linha contra as colunas de ordenação indexadas, com o mesmo ORDER BY da primeira página.

  4. 4

    Busca limit mais um

    Solicita uma linha a mais do que o pedido. Sua presença prova que outra página existe sem executar um count.

  5. 5

    Constrói o próximo cursor

    Codifica a chave de ordenação e o id da última linha retornada em um novo token opaco.

  6. 6

    Retorna data e nextCursor

    Envia a página no envelope. O cursor é null quando não há mais linhas, que é como os clientes sabem que devem parar.

O guia completo

Paginação: Tudo que voce precisa saber

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 reltuples em pg_class e o planner pode estimar com EXPLAIN; 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 + 1 para determinar hasMore em vez de executar um count.
  • Retorne um envelope consistente com data, nextCursor e hasMore em todos os lugares.
  • Trate total como 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 OFFSET para 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 limit de 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.

Na pratica

Offset, keyset, cursor, envelope

Os formatos de consulta e o payload que eles produzem.

queries/offset.sql
-- Page 3 of 20. The database still walks the first 40 rows.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

-- Deep pages pay for every row they skip.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

Keyset vs OFFSET para páginas profundas

OFFSET faz o banco de dados ler e descartar cada linha pulada. Um predicado de keyset busca diretamente a primeira linha desejada, então a página um milhão custa o mesmo que a página um.

Keyset
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
OFFSET
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

-- Reads and throws away a million rows
-- before returning the twenty you asked for.

Cursor estável vs número de página

Um número de página descreve uma posição pela qual as linhas podem se mover. Um cursor descreve a linha onde você parou, então linhas novas ou deletadas não deslocam a janela.

Cursor
GET /posts?limit=20&cursor=eyJjcmVhdGVkQXQiOi...
Número de página
GET /posts?limit=20&page=2

# A row inserted between requests shifts every
# later page, so one item is skipped and another
# is shown twice.

Trade-offs

Cursor ou offset?

Offset é mais fácil de construir e compreender. Cursor é o que mantém uma tabela em crescimento rápida e consistente.

Strengths

  • O custo não cresce com a profundidade

    Uma consulta de keyset busca através do índice, então a milionésima página custa aproximadamente o mesmo que a primeira, em vez de escanear tudo antes dela.

  • Resultados permanecem estáveis

    Como o cursor nomeia uma linha, inserções e exclusões concorrentes não podem fazer com que itens sejam pulados ou repetidos, como acontece com números de página.

  • Composição com filtros

    Qualquer cláusula WHERE restringe o conjunto, e o cursor simplesmente carrega os valores de ordenação da última linha, fazendo com que a filtragem e a paginação se encaixem perfeitamente.

Trade-offs

  • Sem acesso aleatório

    Clientes não podem pular para a página 50. Eles movem-se para frente e, às vezes, para trás, o que é ideal para feeds, mas incômodo para grades de resultados numeradas.

  • Ordenação restringida

    Toda coluna de ordenação deve fazer parte do cursor e ser estável ao longo do tempo. Ordenar por um campo mutável, como updated_at, quebra os cursores conforme as linhas mudam.

  • Cursores não são amigáveis ao usuário

    Um token opaco não pode ser escrito à mão ou favoritado de forma significativa, portanto, a depuração e links compartilháveis exigem cuidado extra.

Perguntas frequentes

Perguntas frequentes

Keep learning

Related topics from the roadmap.

$ comecar a aprender

Pronto para aprender Pagination?

Nosso tutorial interativo te guia por Pagination passo a passo — com quizzes e codigo real que voce pode executar no navegador.