Home > Blog > Desenvolvimento Web
Desenvolvimento Web
JavaScript
Programação

Paginação em APIs Node.js

Atualizado em: 21 de agosto de 2026

Rack de servidores processando fluxos de dados no Node.js

A paginação em APIs Node.js divide coleções grandes em respostas menores, reduzindo tempo de consulta, consumo de memória e volume transferido. Sem paginação, uma rota que lista usuários, pedidos ou eventos pode tentar carregar milhões de registros e comprometer tanto a aplicação quanto o banco de dados.

Existem dois modelos principais: paginação por offset, usando página e limite, e paginação por cursor, usando uma posição estável da coleção. A escolha afeta desempenho, consistência, cache, experiência do cliente e complexidade do contrato.

Neste guia, você aprenderá a validar parâmetros, implementar offset e cursor, escolher ordenação estável, criar links, documentar respostas, proteger o banco, lidar com filtros e testar cenários de concorrência.

Por que paginar?

Uma coleção cresce continuamente. Mesmo que hoje existam apenas centenas de registros, uma API sem limite cria uma dívida operacional. Paginação oferece:

  • respostas previsíveis;
  • menor uso de memória;
  • consultas mais rápidas;
  • controle de largura de banda;
  • melhor experiência no cliente;
  • proteção contra abuso;
  • possibilidade de cache por página.

O padrão de links HTTP é descrito no RFC 8288 sobre Web Linking. Para estratégias de consulta, consulte a documentação do PostgreSQL sobre LIMIT e OFFSET.

Para montar parâmetros corretamente, veja Query String no Node.js. Para documentar o contrato, consulte OpenAPI com Node.js.

Paginação por offset

O modelo mais simples usa page e limit:

GET /api/products?page=3&limit=20

O offset é calculado assim:

const offset = (page - 1) * limit;

A consulta SQL pode ser:

SELECT id, name, price, created_at
FROM products
ORDER BY id
LIMIT $1 OFFSET $2;

Validando parâmetros

function parsePositiveInteger(value, fallback) {
  const parsed = Number(value);

  if (!Number.isInteger(parsed) || parsed < 1) {
    return fallback;
  }

  return parsed;
}

const page = parsePositiveInteger(req.query.page, 1);
const limit = Math.min(
  parsePositiveInteger(req.query.limit, 20),
  100
);

O limite máximo impede que o cliente solicite centenas de milhares de registros. Não confie em coerção automática de strings.

Resposta por página

{
  "data": [],
  "pagination": {
    "page": 3,
    "limit": 20,
    "totalItems": 245,
    "totalPages": 13
  }
}

Esse formato é fácil de usar, mas calcular totalItems pode ser caro em tabelas grandes ou consultas com muitos filtros.

Custo do COUNT

Uma consulta separada pode contar os registros:

SELECT COUNT(*)
FROM products
WHERE category_id = $1;

Índices ajudam, mas o custo ainda cresce. Se o cliente não precisa do total exato, retorne apenas hasNextPage.

Buscando um item extra

const requestedLimit = 20;
const rows = await query({
  limit: requestedLimit + 1,
  offset
});

const hasNextPage = rows.length > requestedLimit;
const data = rows.slice(0, requestedLimit);

Essa técnica evita COUNT e informa se há próxima página.

Problemas do offset alto

Em muitos bancos, OFFSET 500000 exige encontrar e descartar grande quantidade de registros antes de retornar a página. Isso aumenta latência conforme o usuário avança.

Inconsistência durante mudanças

Se novos registros são inseridos entre duas requisições, itens podem aparecer repetidos ou ser ignorados:

  1. cliente busca página 1;
  2. novos itens entram no início;
  3. cliente busca página 2;
  4. o deslocamento agora representa outra posição.

Esse comportamento pode ser aceitável em telas administrativas, mas é ruim em sincronização e processamento sequencial.

Paginação por cursor

Cursor usa um valor da última linha recebida:

GET /api/products?limit=20&after=1250

A consulta usa uma condição:

SELECT id, name, price, created_at
FROM products
WHERE id > $1
ORDER BY id
LIMIT $2;

O banco começa próximo da posição indicada pelo índice, sem descartar todas as linhas anteriores.

Resposta com cursor

{
  "data": [],
  "pageInfo": {
    "hasNextPage": true,
    "endCursor": "eyJpZCI6MTI1MH0"
  }
}

O cursor pode ser opaco para permitir mudanças internas sem alterar o contrato.

Codificando o cursor

function encodeCursor(value) {
  return Buffer
    .from(JSON.stringify(value))
    .toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(
    Buffer.from(cursor, 'base64url').toString('utf8')
  );
}

Base64 não protege o conteúdo. Valide os campos após decodificar.

Cursor assinado

Para evitar manipulação, assine o cursor com HMAC ou use dados que não concedam acesso adicional. Não inclua segredos ou informações pessoais.

Consulte Web Crypto API no Node.js para assinatura e verificação.

Ordenação estável

Um cursor exige ordem determinística. Ordenar apenas por data pode gerar empate:

ORDER BY created_at, id

O cursor precisa guardar os dois valores:

{
  "createdAt": "2026-08-21T15:00:00.000Z",
  "id": 1250
}

Consulta com chave composta

SELECT id, name, created_at
FROM products
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at, id
LIMIT $3;

Crie um índice compatível com a ordenação e os filtros.

Paginação reversa

Para buscar itens anteriores:

WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;

Depois, talvez seja necessário inverter o array para apresentar em ordem crescente.

Filtros e cursor

O cursor deve ser válido apenas com os mesmos filtros e ordenação. Caso o cliente mude categoria, status ou busca, descarte o cursor anterior.

Incluindo filtros no cursor

Você pode armazenar um hash dos filtros:

{
  "position": {
    "createdAt": "2026-08-21T15:00:00.000Z",
    "id": 1250
  },
  "filterHash": "..."
}

Ao receber, compare com os parâmetros atuais.

Ordenação permitida

Nunca concatene uma coluna arbitrária:

const allowedSorts = {
  newest: ['created_at', 'DESC'],
  oldest: ['created_at', 'ASC'],
  name: ['name', 'ASC']
};

A allowlist evita SQL injection e consultas sem índice.

Exemplo com PostgreSQL

async function listProducts({ afterId, limit }) {
  const result = await pool.query(`
    SELECT id, name, price, created_at
    FROM products
    WHERE id > $1
    ORDER BY id
    LIMIT $2
  `, [afterId || 0, limit + 1]);

  const hasNextPage = result.rows.length > limit;
  const rows = result.rows.slice(0, limit);

  return {
    data: rows,
    pageInfo: {
      hasNextPage,
      endCursor: rows.length
        ? encodeCursor({ id: rows.at(-1).id })
        : null
    }
  };
}

Veja Pool PostgreSQL no Node.js para conexões seguras.

ORMs e query builders

Ferramentas como Kysely com TypeScript e Drizzle ORM com PostgreSQL permitem construir as condições com tipos, mas a estratégia de paginação continua sendo responsabilidade da aplicação.

A resposta pode incluir:

Link: </api/products?after=abc>; rel="next"

Também é comum incluir links no JSON:

{
  "links": {
    "self": "/api/products?limit=20",
    "next": "/api/products?limit=20&after=abc"
  }
}

Documentação OpenAPI

Documente:

  • limite padrão e máximo;
  • significado do cursor;
  • ordenações permitidas;
  • filtros compatíveis;
  • formato de pageInfo;
  • erros de cursor inválido;
  • estabilidade da coleção.

Códigos de erro

{
  "code": "INVALID_CURSOR",
  "message": "Cursor inválido ou expirado"
}

Não retorne detalhes internos de assinatura ou parsing.

Cursor expirável

Para coleções sensíveis ou snapshots, inclua timestamp e validade. Um cursor antigo pode apontar para dados removidos; a API deve decidir se continua, reinicia ou retorna erro.

Cache

Páginas por offset são mais fáceis de cachear, mas ficam obsoletas com inserções. Cursores geram URLs específicas e também podem ser cacheados quando a coleção é imutável ou possui política clara.

ETag

Uma página pode retornar ETag para validação condicional. O valor precisa considerar filtros, versão e conteúdo.

Rate limiting

Paginação não impede scraping. Combine limite máximo com rate limiting e autorização. Veja Rate Limiting no Node.js.

Autorização por registro

A consulta deve aplicar escopo do usuário antes de paginar. Não carregue registros proibidos e filtre depois, pois isso altera contagem e pode vazar informações.

Soft delete

Inclua a condição de exclusão lógica no SQL e no índice:

WHERE deleted_at IS NULL
  AND id > $1

Testes

Cubra:

  • primeira página;
  • última página;
  • coleção vazia;
  • limite máximo;
  • parâmetro inválido;
  • cursor alterado;
  • empate de ordenação;
  • inserção entre páginas;
  • remoção entre páginas;
  • filtros diferentes.

Teste de duplicidade

test('não repete itens entre cursores', async () => {
  const first = await list({ limit: 10 });
  const second = await list({
    limit: 10,
    after: first.pageInfo.endCursor
  });

  const firstIds = new Set(first.data.map(item => item.id));
  assert.equal(
    second.data.some(item => firstIds.has(item.id)),
    false
  );
});

Métricas

Monitore:

  • limite solicitado;
  • latência por estratégia;
  • offset máximo;
  • cursores inválidos;
  • consultas sem índice;
  • tamanho de resposta;
  • taxa de páginas vazias.

Erros comuns

  • Sem limite máximo: respostas gigantes consomem recursos.
  • ORDER BY instável: itens repetem ou somem.
  • OFFSET alto: consultas ficam lentas.
  • Cursor sem validação: entradas malformadas chegam ao banco.
  • COUNT obrigatório: cada página executa consulta cara.
  • Ordenação arbitrária: ocorre injection ou full scan.
  • Autorizar depois: contagens e dados podem vazar.

Boas práticas

  • Defina limite padrão e máximo.
  • Valide todos os parâmetros.
  • Use ordenação determinística.
  • Prefira cursor em coleções grandes.
  • Crie índices compatíveis.
  • Evite COUNT quando não for necessário.
  • Use cursores opacos e validados.
  • Documente filtros e ordenações.
  • Teste mudanças concorrentes.
  • Monitore offsets e latência.

Conclusão

A paginação em APIs Node.js protege aplicação e banco ao dividir coleções em respostas previsíveis. Offset é simples e adequado para listas pequenas ou navegação por páginas, enquanto cursor oferece melhor desempenho e consistência em coleções grandes e mutáveis.

A implementação correta depende de validação, limite máximo, ordenação estável, índices e testes com inserções concorrentes. Com um contrato claro e métricas de uso, a API consegue crescer sem transformar rotas de listagem em gargalos.

Os 10 Melhores Cursos de Programação de 2026

Descubra os melhores cursos de programação. Aprenda a escolher o curso ideal para iniciar ou avançar na carreira de desenvolvedor

POSTS RELACIONADOS

Ver todos

Seta para a direita