12k
All articles

Padrões de Paginação para APIs Modernas

Compare paginação offset, cursor, keyset, page tokens e Relay GraphQL com exemplos em TypeScript e SQL para APIs modernas.

OpenReplay Team
OpenReplay Team
Padrões de Paginação para APIs Modernas

Para novos endpoints de listagem, adote como padrão a paginação por cursor com suporte em consultas keyset — ela oferece tempo de consulta quase constante independentemente do tamanho do conjunto de dados e permanece estável quando linhas são inseridas ou excluídas entre buscas de páginas. Use paginação por offset apenas quando os usuários precisam saltar para um número de página arbitrário, o conjunto de dados é pequeno e raramente atualizado, ou quando uma contagem total de páginas é um requisito obrigatório da interface. A decisão gira em torno de dois modos de falha dos quais a paginação por offset não consegue escapar: degradação linear em offsets profundos e um bug de janela deslizante que duplica ou pula itens quando os dados mudam durante a travessia. Este artigo mapeia todos os padrões da família — offset, cursor, keyset, page tokens e conexões GraphQL no estilo Relay — com TypeScript executável e o SQL que sustenta cada um.

Principais Conclusões

  • Cursor e keyset não são sinônimos: um cursor é o token opaco que sua API retorna ao cliente; keyset é a técnica SQL — um predicado composto WHERE (sort_col, id) < (val, id) — que torna a paginação por cursor eficiente ao permitir que o banco de dados faça uma busca direta no índice em vez de varrer e descartar linhas.
  • Uma consulta com OFFSET 500000 LIMIT 20 não pula 500.000 linhas; o PostgreSQL as lê e descarta antes de retornar as 20, portanto o tempo de consulta cresce com o valor do offset independentemente dos índices na coluna de ordenação.
  • Quando linhas são inseridas entre buscas de páginas, a paginação por offset desloca a janela: o cliente recebe um item duplicado e silenciosamente pula outro, sem nenhum erro na resposta.
  • Retornar total_count em cada resposta força um COUNT(*) separado; prefira has_next_page, derivado de forma econômica buscando limit + 1 linhas e verificando se a linha extra existe.
  • Um cursor codifica uma posição em um conjunto de resultados ordenado e filtrado específico, portanto ele deve ser descartado e reiniciado para a primeira página sempre que os parâmetros de ordenação ou filtro forem alterados.

Por Que a Estratégia de Paginação Importa: Dois Modos de Falha

A estratégia de paginação importa porque o padrão ingênuo — LIMIT e OFFSET — possui dois modos de falha estruturais que só se manifestam em escala ou sob escritas concorrentes. O primeiro é de desempenho: offsets profundos forçam o banco de dados a ler e descartar linhas. O segundo é de consistência: offsets são posicionais, então qualquer inserção ou exclusão entre buscas de páginas desloca a janela e corrompe o conjunto de resultados. Nenhum dos dois bugs aparece em uma tabela pequena e estática, razão pela qual a paginação por offset parece funcionar bem em desenvolvimento e quebra em produção.

Aqui está a comparação que o restante do artigo justifica:

PadrãoAcesso aleatório a páginasEstável sob escritasDesempenho do BD em profundidadeContagem totalMelhor para
Offset / limitSimNãoDegrada linearmenteRazoavelmente barato (consulta extra)Dados estáticos pequenos, tabelas administrativas, UIs de busca com numeração de páginas
Cursor (superfície da API)NãoSimConstante com keysetOmitir; usar has_moreScroll infinito, feeds, APIs de listagem públicas
Keyset (técnica de BD)NãoSimConstante (busca por índice)DifícilA implementação por baixo de uma API com cursor
Page tokenNãoSimDefinido pela implementaçãoOpcionalAPIs que desejam ocultar e evoluir sua estratégia
Relay connectionNãoSimConstante com keysetOpcionalClientes GraphQL usando a especificação de conexões

As duas mecânicas à direita — desempenho e estabilidade — são o que separa os padrões. Tudo abaixo explica o porquê.

Paginação por Offset: Como o Bug de Janela Deslizante Quebra Tudo

A paginação por offset usa page e limit (ou offset e limit) para calcular uma posição numérica: offset = (page - 1) * limit. É o único padrão que suporta saltar para um número de página arbitrário, o que o torna a escolha certa para tabelas administrativas e resultados de busca onde os usuários clicam diretamente em “página 7”. Suas fraquezas são ambas consequências desse endereçamento posicional.

A fraqueza de desempenho é um comportamento documentado, não uma peculiaridade de implementação. Conforme a documentação do PostgreSQL sobre LIMIT e OFFSET, “as linhas ignoradas por uma cláusula OFFSET ainda precisam ser computadas internamente pelo servidor.” Uma consulta com OFFSET 500000 LIMIT 20 não pula 500.000 linhas — o banco de dados as lê e descarta antes de retornar as 20, o que significa que o tempo de consulta cresce com o valor do offset independentemente dos índices na coluna de ordenação. Essa semântica permanece inalterada nas versões recentes do PostgreSQL (16 a 18).

A fraqueza de consistência é o bug de janela deslizante. Quando linhas são inseridas no topo de um feed entre buscas de páginas, a paginação por offset desloca a janela: o item na posição offset na página N+1 é o item que estava na posição offset - 1 na página N, então o cliente recebe um duplicado — e o item que saiu do limite é silenciosamente ignorado, sem nenhum erro na resposta. Uma exclusão produz o efeito espelho: a janela se contrai e um item é pulado.

Aqui está um endpoint de offset correto em TypeScript com Express e pg 8.22.x, rodando no Node 24 (Active LTS):

// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
import express from "express";
import { Pool } from "pg";

const pool = new Pool();
const app = express();

interface OffsetPage<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    has_next_page: boolean;
  };
}

function clampLimit(raw: unknown): number {
  const n = Number(raw) || 20;
  return Math.min(Math.max(Math.trunc(n), 1), 100); // clamp 1–100
}

app.get("/posts", async (req, res) => {
  const limit = clampLimit(req.query.limit);
  const page = Math.max(Number(req.query.page) || 1, 1);
  const offset = (page - 1) * limit;

  // Fetch limit + 1 to learn has_next_page without a COUNT query
  const { rows } = await pool.query(
    `SELECT id, title, created_at
       FROM posts
       ORDER BY created_at DESC, id DESC
       LIMIT $1 OFFSET $2`,
    [limit + 1, offset]
  );

  const has_next_page = rows.length > limit;
  const data = rows.slice(0, limit);

  const body: OffsetPage<typeof data[number]> = {
    data,
    pagination: { page, limit, has_next_page },
  };
  res.json(body);
});

Dois detalhes merecem atenção aqui. A função clampLimit limita o tamanho da página a 100 para que um cliente não possa requisitar limit=1000000 e esgotar o servidor. E buscar limit + 1 linhas permite que o endpoint informe has_next_page sem uma segunda consulta COUNT(*) — o mesmo truque que a implementação com cursor usa abaixo. Se a linha extra existir, há pelo menos mais uma página.

Paginação por Cursor: A Superfície da API

A paginação por cursor substitui o offset numérico por um token opaco que marca uma posição fixa no conjunto de resultados. O cliente envia o token de volta para buscar a próxima página e, como o token ancora nos valores de ordenação reais de uma linha em vez de uma contagem, inserções e exclusões em outras partes da tabela não corrompem a janela. A contrapartida é a perda do acesso aleatório a páginas: não existe “página 7”, apenas “a página após este cursor.”

Cursor e keyset não são sinônimos, e confundi-los é o erro mais comum em textos sobre paginação. Um cursor é o token opaco que sua API retorna ao cliente, ocultando a estratégia de paginação; keyset é a técnica SQL — um predicado composto WHERE (sort_col, id) < (val, id) — que torna a paginação por cursor eficiente ao permitir que o banco de dados use uma busca por índice em vez de varrer e descartar linhas. Você pode implementar uma API com cursor usando uma consulta por offset por baixo (não faça isso — ela herda a lentidão em offsets profundos) ou com uma consulta keyset por baixo (faça isso). O cursor é a interface; keyset é o motor eficiente.

O token em si é apenas uma posição codificada — tipicamente a coluna de ordenação e um desempate, codificados em base64 para que os clientes o tratem como opaco e não dependam de sua estrutura interna. Mantenha-o opaco para que você possa alterar seu conteúdo posteriormente sem quebrar clientes no meio de uma travessia.

// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
interface CursorPage<T> {
  data: T[];
  pagination: {
    next_cursor: string | null;
    has_more: boolean;
  };
}

type Cursor = { created_at: string; id: number };

function encodeCursor(c: Cursor): string {
  return Buffer.from(JSON.stringify(c)).toString("base64url");
}

function decodeCursor(raw: string): Cursor {
  return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
}

app.get("/feed", async (req, res) => {
  const limit = clampLimit(req.query.limit);
  const cursor = req.query.cursor
    ? decodeCursor(String(req.query.cursor))
    : null;

  // Keyset predicate: compound row comparison on the sort tuple
  const { rows } = cursor
    ? await pool.query(
        `SELECT id, title, created_at
           FROM posts
          WHERE (created_at, id) < ($1, $2)
          ORDER BY created_at DESC, id DESC
          LIMIT $3`,
        [cursor.created_at, cursor.id, limit + 1]
      )
    : await pool.query(
        `SELECT id, title, created_at
           FROM posts
          ORDER BY created_at DESC, id DESC
          LIMIT $1`,
        [limit + 1]
      );

  const has_more = rows.length > limit;
  const data = rows.slice(0, limit);
  const last = data[data.length - 1];

  const body: CursorPage<typeof data[number]> = {
    data,
    pagination: {
      has_more,
      next_cursor:
        has_more && last
          ? encodeCursor({ created_at: last.created_at, id: last.id })
          : null,
    },
  };
  res.json(body);
});

O endpoint decodifica o cursor em uma tupla (created_at, id), executa uma consulta keyset contra ela, busca limit + 1 para calcular has_more e codifica o próximo cursor a partir da última linha retornada. Sem COUNT(*), sem offset, sem varredura de linhas descartadas.

Paginação Keyset: O Predicado Composto

A paginação keyset é a técnica de banco de dados que torna a paginação por cursor eficiente: em vez de um offset, ela filtra pela própria chave de ordenação com um predicado composto. O PostgreSQL suporta comparações de valores de linha, então (created_at, id) < ($1, $2) compara a tupla lexicograficamente e permite que um índice composto correspondente busque diretamente até o limite.

O desempate não é opcional. created_at sozinho não é único — dois posts podem compartilhar um timestamp até o milissegundo — e uma ordenação em uma coluna não única produz uma ordem não determinística, o que significa que um cursor ancorado em created_at poderia pular ou repetir linhas que compartilham esse valor. Acrescentar a chave primária (id) torna a tupla de ordenação única e o limite determinístico.

-- PostgreSQL 18
-- Composite index matching the sort tuple, including its direction.
CREATE INDEX idx_posts_cursor ON posts (created_at DESC, id DESC);

-- Keyset query: compound predicate seeks into the index.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;

A direção do índice importa. Conforme a documentação do PostgreSQL sobre índices e ORDER BY, um índice B-tree pode ser varrido em qualquer direção, mas fazer a ordem declarada do índice coincidir com o ORDER BY da consulta permite que o planejador satisfaça tanto o predicado quanto a ordenação a partir de uma única varredura de índice. Execute EXPLAIN ANALYZE em ambas as formas contra uma tabela grande e a diferença estrutural fica visível no plano: a consulta com OFFSET 500000 mostra o servidor computando e descartando as linhas iniciais, enquanto a consulta keyset mostra uma varredura de índice com baixo custo de inicialização que retorna apenas as linhas necessárias. A diferença de custo é estrutural, não acidental — ela decorre diretamente da semântica documentada do OFFSET mencionada acima.

Page Tokens: Ocultando a Estratégia

Page tokens são a variante de estratégia opaca da paginação por cursor, usada de forma mais clara pelas diretrizes de design de API do Google Cloud. Sob o AIP-158, a requisição carrega page_size e page_token, a resposta retorna um next_page_token, e a especificação exige que os page tokens sejam opacos para o cliente. Codificar o estado completo de paginação no servidor permite que o servidor mude sua estratégia interna — de offset para keyset, por exemplo — sem quebrar clientes que estão no meio de uma travessia.

O Stripe é frequentemente citado ao lado do Google aqui, mas os dois não são o mesmo mecanismo, e a distinção vale ser esclarecida. Conforme a documentação de paginação do Stripe, a API de listagem do Stripe usa cursores de ID de objeto (starting_after e ending_before, com limit e um flag has_more), não page tokens opacos; é a API de busca que retorna um token opaco next_page. Portanto, o levantamento preciso é: o Google Cloud usa page tokens opacos, os endpoints de listagem do Stripe usam cursores de ID de objeto, o Shopify usa conexões baseadas em cursor via sua API GraphQL Admin, e a API REST do GitHub pagina via o cabeçalho Link — baseado em número de página na maioria dos endpoints, com cursor (before/after) apenas em endpoints específicos. O fio condutor comum é a travessia no estilo cursor, não um único mecanismo uniforme.

Conexões GraphQL no Estilo Relay

As conexões Relay são a especialização GraphQL da paginação por cursor, formalizada para que todo campo de lista em um schema seja paginado da mesma forma. A Especificação de Conexões por Cursor do Relay define uma conexão como uma lista de edges, onde cada edge tem um node (o item) e um cursor (sua posição opaca), além de um objeto pageInfo expondo hasNextPage, hasPreviousPage, startCursor e endCursor.

query {
  posts(first: 10, after: "cursor123") {
    edges {
      cursor
      node { id title }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

O cursor por edge é o mesmo conceito de token opaco das seções REST, e pageInfo.hasNextPage é o mesmo flag has_more derivado do truque de buscar uma linha extra. A estrutura é mais pesada do que um envelope simples { data, pagination }, mas oferece aos clientes um contrato uniforme: paginar qualquer conexão passando first/after e lendo pageInfo. Por baixo, uma conexão Relay ainda deve ser sustentada por uma consulta keyset pelos mesmos motivos de desempenho.

Escolhendo um Padrão e Evitando as Armadilhas Comuns

Adote como padrão a paginação por cursor com suporte keyset para qualquer novo endpoint de listagem, e recorra ao offset apenas quando um requisito específico o exigir. A matriz de decisão:

Caso de usoPadrão
Feed de scroll infinito, stream de atividades, notificaçõesCursor + keyset
API de listagem pública que você pode querer reimplementar depoisPage token (opaco)
Campo de lista GraphQLRelay connection (keyset por baixo)
Exportação em massa de uma tabela grandeCursor + keyset
Tabela administrativa com “saltar para a página N”Offset
UI de busca que exibe números de páginaOffset
Dados de referência pequenos (<10k linhas), raramente alteradosOffset (qualquer um funciona)

Um conjunto de práticas mantém todos esses padrões corretos em produção:

  • Limite o tamanho da página. Restrinja limit a um máximo fixo (100 é um teto comum) para que um cliente não possa requisitar uma página sem limite.
  • Indexe a tupla de ordenação completa. O índice composto deve corresponder a ambas as colunas e direções do ORDER BY, ou a consulta keyset recai em uma varredura.
  • Prefira has_next_page em vez de contagens totais. Retornar total_count em cada resposta força um COUNT(*) separado, cujo custo em uma tabela grande depende do inchaço da tabela e do mapa de visibilidade; derive has_next_page de forma econômica a partir da linha limit + 1 e reserve contagens totais para UIs que genuinamente as exigem.
  • Mantenha os cursores opacos. Codifique-os para que os clientes não possam depender do formato interno, deixando você livre para mudar a estratégia subjacente.
  • Redefina o cursor ao mudar filtro ou ordenação. Um cursor codifica uma posição em um conjunto de resultados ordenado e filtrado específico; quando o usuário muda uma ordem de classificação ou aplica um novo filtro, esse cursor é inválido para a nova consulta e deve ser descartado — redefina para a primeira página sempre que os parâmetros de ordenação ou filtro forem alterados. No cliente, isso significa vincular o estado do cursor ao estado do filtro para que uma mudança de filtro limpe as páginas acumuladas e refaça a busca.

Os bugs de janela deslizante e de offset profundo são fáceis de perder em testes porque só aparecem sob escritas concorrentes ou em profundidade, e vários deles nunca chegam aos seus logs de erro. O bug de janela deslizante é invisível nos logs do servidor — a resposta é um 200 válido com o número correto de itens — mas em session replay o vemos como um artefato de renderização específico: um item que já estava visível na viewport é renderizado novamente no próximo lote, imediatamente adjacente à sua posição anterior. A lentidão em offsets profundos aparece da mesma forma: não como um timeout ou um erro 4xx, mas como um spinner de “carregar mais” que nunca resolve em um 200 lento que o cliente ainda está aguardando. Como esses são sintomas de sequência de interação em vez de erros discretos, o session replay é frequentemente como os identificamos — eles residem na sequência completa de scroll, busca e renderização, não em uma única requisição com falha.

Conclusão

Adote a paginação por cursor com suporte em consulta keyset no próximo endpoint de listagem que você construir, e trate o offset como a exceção que você justifica em vez do padrão que você herda — seu custo linear em offsets profundos e a instabilidade da janela deslizante são estruturais, não ajustáveis. Adicione o índice composto na sua tupla de ordenação, retorne has_next_page em vez de uma contagem total e mantenha o cursor opaco para que você possa trocar o motor por baixo sem uma mudança que quebre a compatibilidade.

Perguntas Frequentes

Posso adicionar paginação por cursor a uma API existente baseada em offset sem quebrar os clientes?

Sim, mas trate isso como um novo parâmetro em vez de uma substituição. Aceite um parâmetro de consulta cursor ao lado dos parâmetros existentes page e limit, e direcione as requisições que carregam um cursor para a consulta keyset enquanto mantém as requisições baseadas em página no caminho de offset. Mantenha o cursor opaco para que o formato interno permaneça livre para mudar, e documente o caminho de offset como obsoleto para endpoints grandes ou com muitas escritas em vez de removê-lo imediatamente.

O que acontece com um cursor quando a linha para a qual ele aponta é excluída?

Nada quebra, porque um cursor keyset codifica valores de ordenação em vez de uma referência de linha. O predicado composto (created_at, id) < (val, id) seleciona todas as linhas ordenadas após aquela tupla limite, então a próxima página retorna corretamente mesmo que a linha âncora exata não exista mais. Essa é a vantagem central sobre a paginação por offset, onde uma exclusão desloca todas as posições subsequentes e faz o cliente silenciosamente pular um item.

Por que usar JSON codificado em base64 para um cursor em vez de simplesmente enviar o timestamp e o ID diretamente?

A codificação torna o cursor opaco, o que sinaliza aos clientes que o token não deve ser analisado ou construído manualmente. Se os clientes lerem o timestamp e o ID brutos, eles se acoplam à sua estratégia interna de paginação e quebram quando você a altera. Um token base64 opaco permite que você adicione campos, troque chaves de ordenação ou migre de keyset para page tokens sem uma mudança no lado do cliente. A codificação não adiciona overhead significativo.

A paginação por cursor funciona ao ordenar por uma coluna diferente de created_at, como uma pontuação de popularidade?

Sim, desde que o cursor codifique a mesma tupla de ordenação pela qual a consulta ordena e termine com um desempate único. Ordenar por uma pontuação de popularidade requer que o predicado e o índice cubram (score, id) e que o cursor carregue ambos os valores, já que pontuações colidem muito mais frequentemente do que timestamps. Sem o desempate único por id, linhas que compartilham uma pontuação produzem uma ordem não determinística que pode pular ou repetir itens entre páginas.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.