Offset vs Cursor vs Keyset: qual paginação usar na sua API Node.js
← Voltar para Codeshort

Offset vs Cursor vs Keyset: qual paginação usar na sua API Node.js

Offset, cursor, keyset — cada estratégia tem um custo que só aparece em produção. Veja benchmarks reais e código pronto para Node.js.

DC
Dev Code Software
18 de maio de 2026·10 min de leitura

Você subiu uma listagem paginada, testou com 200 registros, funcionou, e esqueceu. Três meses depois o banco tem 800 mil linhas, o OFFSET 50000 está varrendo a tabela inteira a cada clique de página, e o DBA está te mandando mensagem no Slack.

Não é exagero. É o ciclo de vida padrão de endpoints paginados que nasceram com offset e nunca foram revisitados.

Este artigo cobre as três estratégias principais, com benchmarks reais de PostgreSQL, código TypeScript pronto para usar, e uma tabela de decisão para você parar de adivinhar qual usar em cada cenário.


O dia que o OFFSET travou o banco em produção

Em um sistema de e-commerce com ~600 mil pedidos, uma query de listagem administrativa com OFFSET 40000 LIMIT 50 estava consumindo 4,2 segundos no p95. Com índice. Com EXPLAIN ANALYZE, o plano mostrava um Seq Scan seguido de Sort para descartar as primeiras 40.000 linhas antes de devolver as 50 que importavam.

O problema não era falta de índice. Era o funcionamento do próprio OFFSET: o banco não pula registros — ele os lê, processa, descarta, e entrega o que sobra. Quanto mais fundo na paginação, mais trabalho desperdiçado.

Existem três estratégias para resolver isso. Cada uma com um trade-off diferente.


Offset pagination: quando funciona e quando quebra

Offset é o padrão que todo dev aprende primeiro porque o SQL é óbvio e a URL fica intuitiva (?page=3&limit=20). Funciona. Até um certo ponto.

SELECT id, name, price
FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

O que o banco faz internamente: lê as primeiras 60 linhas na ordem definida pelo ORDER BY, descarta as 40 primeiras, devolve as 20 restantes. Em OFFSET 50000, ele lê e descarta 50.000 linhas a cada request. O custo cresce de forma linear com a profundidade da página.

Segundo problema: dados duplicados no scroll infinito. Imagine que o usuário está na página 2 e alguém insere um produto novo no banco nesse momento. O produto que estava na posição 21 agora está na 22. Quando o usuário avança para a página 3, o item que encerrou a página 2 aparece de novo no início — porque o offset ficou defasado em relação ao estado atual da tabela.

Implementação correta com contagem otimizada

interface OffsetResult<T> {
  data: T[];
  total: number;
  page: number;
  totalPages: number;
  hasMore: boolean;
}

async function paginateOffset<T>(
  tableName: string,
  columns: string,
  orderBy: string,
  page: number,
  limit: number
): Promise<OffsetResult<T>> {
  const offset = (page - 1) * limit;

  const [rows, countRes] = await Promise.all([
    db.query<T>(
      `SELECT ${columns} FROM ${tableName} ORDER BY ${orderBy} LIMIT $1 OFFSET $2`,
      [limit, offset]
    ),
    db.query<{ estimate: string }>(
      `SELECT reltuples::BIGINT AS estimate
       FROM pg_stat_user_tables
       WHERE relname = $1`,
      [tableName]
    ),
  ]);

  const total = parseInt(countRes.rows[0]?.estimate ?? "0");

  return {
    data: rows.rows,
    total,
    page,
    totalPages: Math.ceil(total / limit),
    hasMore: offset + limit < total,
  };
}

Por que pg_stat_user_tables em vez de COUNT(*)? O COUNT(*) faz um full scan em tabelas sem WHERE. Em tabelas grandes, isso pode custar 200–800ms por request. A view pg_stat_user_tables devolve uma estimativa atualizada pelo autovacuum do PostgreSQL com latência de microssegundos. A margem de erro é de ~1–5%, suficiente para qualquer UI que mostra "aproximadamente X resultados".

Quando usar offset:

  • Listas com menos de 50k registros
  • Painéis administrativos onde o usuário precisa ir direto para a página 47
  • Relatórios com dados estáticos ou que mudam raramente

Cursor-based: a escolha certa para volumes reais

Cursor pagination muda o contrato da API: em vez de pedir "a página 3", o cliente pede "os próximos 20 itens depois deste". O "deste" é o cursor — uma referência ao último item visto, opaca para o cliente.

A query nunca usa OFFSET. Ela usa uma condição WHERE id > $cursor que o banco resolve com um index scan direto. Independente de quantos registros existam antes do cursor, a query tem custo constante.

const encodeCursor = (payload: Record<string, unknown>): string =>
  Buffer.from(JSON.stringify(payload)).toString("base64url");

const decodeCursor = (cursor: string): Record<string, unknown> =>
  JSON.parse(Buffer.from(cursor, "base64url").toString());

interface CursorResult<T> {
  data: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

async function paginateCursor<T extends { id: number }>(
  tableName: string,
  columns: string,
  cursor: string | null,
  limit = 20
): Promise<CursorResult<T>> {
  const decoded = cursor ? decodeCursor(cursor) : null;

  const query = decoded
    ? `SELECT ${columns} FROM ${tableName} WHERE id > $1 ORDER BY id ASC LIMIT $2`
    : `SELECT ${columns} FROM ${tableName} ORDER BY id ASC LIMIT $1`;

  const params = decoded ? [decoded.id, limit] : [limit];
  const result = await db.query<T>(query, params);
  const last = result.rows.at(-1);

  return {
    data: result.rows,
    nextCursor: result.rows.length === limit && last
      ? encodeCursor({ id: last.id })
      : null,
    hasMore: result.rows.length === limit,
  };
}

Por que codificar o cursor em base64? Dois motivos: evitar que o cliente tente manipular o valor diretamente (passar id=1 para varrer o banco sequencialmente), e desacoplar o contrato da API da estrutura interna do banco. Se você migrar de ID numérico para UUID, o cursor codificado absorve a mudança sem quebrar clientes existentes.

A resposta da API fica assim:

{
  "data": [{ "id": 1234, "name": "Produto X", "price": 99.90 }],
  "nextCursor": "eyJpZCI6MTIzNH0",
  "hasMore": true
}

Quando usar cursor:

  • Feeds com atualizações frequentes
  • Scroll infinito em mobile ou web
  • Qualquer endpoint com mais de 50k registros
  • Quando dados duplicados entre páginas são inaceitáveis

Keyset pagination: máxima performance com índice composto

Keyset é uma extensão do cursor que usa os valores reais das colunas de ordenação como referência. A diferença prática: funciona com campos não-sequenciais (timestamps, preços, strings) e aproveita ao máximo índices compostos.

SELECT id, name, created_at
FROM products
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

A comparação (created_at, id) < ($1, $2) é uma comparação de tupla — funciona nativamente no PostgreSQL e permite que o planner use um índice composto (created_at DESC, id DESC) sem nenhum trabalho extra.

Benchmark comparativo em tabela com 1 milhão de registros (PostgreSQL 16):

EstratégiaPágina 1Página 500Página 5000
OFFSET3ms180ms1.800ms
Cursor (id)2ms2ms2ms
Keyset1ms1ms1ms
interface KeysetCursor {
  afterDate: string;
  afterId: number;
}

interface KeysetResult<T> {
  data: T[];
  next: KeysetCursor | null;
}

async function paginateKeyset<T extends { id: number; created_at: string }>(
  cursor: KeysetCursor | null,
  limit = 20
): Promise<KeysetResult<T>> {
  const query = cursor
    ? `SELECT id, name, created_at FROM products
       WHERE (created_at, id) < ($1, $2)
       ORDER BY created_at DESC, id DESC
       LIMIT $3`
    : `SELECT id, name, created_at FROM products
       ORDER BY created_at DESC, id DESC
       LIMIT $1`;

  const params = cursor
    ? [cursor.afterDate, cursor.afterId, limit]
    : [limit];

  const { rows } = await db.query<T>(query, params);
  const last = rows.at(-1);

  return {
    data: rows,
    next: rows.length === limit && last
      ? { afterDate: last.created_at, afterId: last.id }
      : null,
  };
}

Índice obrigatório: crie o índice composto antes de usar keyset em produção. Sem ele, a query faz full scan e o keyset não tem vantagem nenhuma sobre o offset.

CREATE INDEX idx_products_created_id
ON products (created_at DESC, id DESC);

Quando usar keyset:

  • Volume alto com performance crítica (> 500k registros)
  • Ordenação por campos não-sequenciais (preço, rating, nome)
  • Quando cursor por ID simples não resolve a ordenação necessária

Implementando os três modos em Node.js

Um wrapper unificado que expõe os três modos com tipagem completa:

type PaginationMode = "offset" | "cursor" | "keyset";

interface PaginationOptions {
  mode: PaginationMode;
  limit?: number;
  page?: number;
  cursor?: string;
  keysetCursor?: KeysetCursor;
}

interface PaginatedResult<T> {
  data: T[];
  meta: {
    limit: number;
    total?: number;
    page?: number;
    totalPages?: number;
    nextCursor?: string;
    next?: KeysetCursor;
    hasMore: boolean;
  };
}

async function paginate<T extends { id: number; created_at: string }>(
  tableName: string,
  columns: string,
  options: PaginationOptions
): Promise<PaginatedResult<T>> {
  const limit = options.limit ?? 20;

  switch (options.mode) {
    case "offset": {
      const result = await paginateOffset<T>(
        tableName,
        columns,
        "created_at DESC, id DESC",
        options.page ?? 1,
        limit
      );
      return {
        data: result.data,
        meta: {
          limit,
          total: result.total,
          page: result.page,
          totalPages: result.totalPages,
          hasMore: result.hasMore,
        },
      };
    }

    case "cursor": {
      const result = await paginateCursor<T>(
        tableName,
        columns,
        options.cursor ?? null,
        limit
      );
      return {
        data: result.data,
        meta: {
          limit,
          nextCursor: result.nextCursor ?? undefined,
          hasMore: result.hasMore,
        },
      };
    }

    case "keyset": {
      const result = await paginateKeyset<T>(
        options.keysetCursor ?? null,
        limit
      );
      return {
        data: result.data,
        meta: {
          limit,
          next: result.next ?? undefined,
          hasMore: !!result.next,
        },
      };
    }
  }
}

Paginação no frontend: scroll infinito sem vazamento de memória

A estratégia do backend precisa casar com o que o frontend espera. Scroll infinito pede cursor. Tabela com numeração de páginas pede offset.

interface Product {
  id: number;
  name: string;
  price: number;
}

function useInfiniteProducts() {
  const [pages, setPages] = useState<Product[][]>([]);
  const [nextCursor, setNextCursor] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const loadMore = async () => {
    if (loading || !hasMore) return;
    setLoading(true);

    const url = new URL("/api/products", window.location.origin);
    url.searchParams.set("limit", "20");
    if (nextCursor) url.searchParams.set("cursor", nextCursor);

    const res = await fetch(url.toString());
    const json: { data: Product[]; nextCursor: string | null; hasMore: boolean } =
      await res.json();

    setPages((prev) => [...prev, json.data]);
    setNextCursor(json.nextCursor);
    setHasMore(json.hasMore);
    setLoading(false);
  };

  const items = useMemo(() => pages.flat(), [pages]);

  return { items, loadMore, loading, hasMore };
}

Atenção ao vazamento de memória: acumular todas as páginas em memória funciona até centenas de itens. Em listas longas (> 500 itens renderizados), use virtualização com @tanstack/react-virtual ou react-window. O componente renderiza só o que está visível na viewport e descarta o resto do DOM — o usuário não percebe, a memória agradece.


Erros que aparecem só depois do deploy

Ordenação ambígua entre páginas. Se dois registros têm o mesmo created_at, a ordem entre eles não é determinística. Sem desempate por id, o mesmo item pode aparecer em duas páginas diferentes dependendo do estado interno do banco.

-- Nunca faça isso em paginação
ORDER BY created_at DESC

-- Sempre adicione desempate com campo único
ORDER BY created_at DESC, id DESC

Cursor sem índice na coluna de ordenação. O cursor é rápido porque usa um index scan. Se a coluna de ordenação não tem índice, a query faz full scan — tão ruim quanto o offset, mas sem o benefício de permitir navegação direta por página.

COUNT(*) em cada request de listagem. SELECT COUNT(*) FROM orders em uma tabela com 2 milhões de linhas pode levar 400ms. Se você precisa de um total aproximado para mostrar "~1.200 resultados encontrados", use pg_stat_user_tables. Se precisa de total exato para filtros complexos, execute a contagem de forma assíncrona e cache no Redis com TTL de 30 segundos.

SELECT reltuples::BIGINT AS estimate
FROM pg_stat_user_tables
WHERE relname = 'orders';

Vazar estrutura interna pelo cursor. Nunca exponha o ID bruto como cursor. Além de revelar a estrutura do banco, permite que alguém itere sequencialmente por todos os seus registros com requests simples. Sempre codifique em base64 ou assine com HMAC.


FAQ

Posso adicionar cursor pagination em um endpoint que já usa offset sem quebrar os clientes?

Sim. A migração menos traumática é aceitar ambos os parâmetros no mesmo endpoint e detectar o modo pela presença de cursor na query string. Quando cursor está presente, ignore page e retorne nextCursor. Quando cursor está ausente, use o comportamento de offset original. Documente a deprecação do modo offset no OpenAPI e comunique um prazo para os clientes migrarem.

Qual o tamanho de limite ideal? 20, 50, 100?

Depende do tamanho do payload. Para objetos leves (IDs, nomes, preços), 50–100 é razoável. Para objetos com muitos campos ou campos grandes (descrições, URLs de imagem), fique em 20–30. A métrica real: meça o tempo de serialização + transferência com o payload completo no p95 e mantenha abaixo de 300ms. Acima disso, reduza o limite ou projete os campos (SELECT id, name em vez de SELECT *).

Cursor pagination funciona com filtros e ordenação por múltiplos campos?

Funciona, mas o cursor precisa codificar todos os campos usados na ordenação. Se você ordena por price ASC, id ASC, o cursor precisa conter { price, id }, e a query precisa reproduzir exatamente essa lógica na cláusula WHERE. Para ordenações muito complexas, keyset com tupla composta é mais robusto que cursor simples.

Como mostro "1.234 resultados" no modo cursor?

Execute uma query separada com os mesmos filtros mas sem paginação — e cache o resultado no Redis por 30–60 segundos. O total não precisa ser atualizado a cada clique; para o usuário, uma estimativa estável é indistinguível de um valor exato.

ORM ou SQL puro para paginação?

Para offset simples, use o ORM — menos código, mesmos resultados. Para cursor e keyset com múltiplos campos de ordenação, SQL puro dá mais controle sobre o plano de execução e evita surpresas com a query gerada automaticamente. Prisma suporta cursor-based nativamente com cursor e skip, mas não suporta comparação de tupla para keyset sem raw query.


Qual estratégia usar? Tabela de decisão

CenárioEstratégia recomendadaMotivo
Lista pequena (< 50k), navegação por páginaOffset com estimativa de totalSimplicidade, UX familiar
Feed ou scroll infinitoCursor com codificação opacaSem duplicatas, custo constante
Volume alto, performance críticaKeyset com índice compostoCusto mínimo, escala linear
Relatório ou exportaçãoOffset sem paginação visualDados estáticos, sem concorrência
API pública com clientes variadosCursor como padrão, offset como legadoFlexibilidade na migração

Se você tem um endpoint com offset hoje e quer migrar:

  1. Adicione suporte a cursor como parâmetro opcional — mantenha offset como padrão enquanto cursor não é passado
  2. Crie o índice composto antes de ativar o novo modo em produção
  3. Documente os dois contratos no OpenAPI com data de deprecação para o modo offset
  4. Monitore o EXPLAIN ANALYZE dos dois modos por uma semana antes de desligar o offset

Paginação bem implementada é invisível: o usuário nunca sabe que existe. Paginação mal implementada você descobre quando o DBA te chama às 11 da noite porque o banco está com 100% de CPU e o culpado é um OFFSET 200000 que ninguém tinha revisado.