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=20O 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:
- cliente busca página 1;
- novos itens entram no início;
- cliente busca página 2;
- 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=1250A 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, idO 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.
Links HTTP
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 > $1Testes
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.



