Paginação é essencial para qualquer API que retorne listas — sem ela, uma consulta que retorna milhões de linhas trava seu servidor e sobrecarrega os clientes. Mas existem diversas estratégias de paginação com características de desempenho muito diferentes. Este guia cobre todos eles e quando usar cada um.
📋 Table of Contents
- Por que a paginação é importante
- Estratégia 1: Paginação de deslocamento/limite (simples)
- Estratégia 2: Paginação do Cursor (Escalável)
- Estratégia 3: Paginação do conjunto de chaves (melhor desempenho)
- Comparação: qual usar
- Formato de resposta consistente
- Dicas de desempenho
- Perguntas Frequentes
- Conclusão
Por que a paginação é importante
- Desempenho: Retornar todas as linhas é lento e consome muita memória
- Largura de banda: Os clientes não precisam de milhares de registros de uma vez
- Carregamento do banco de dados: Consultas limitadas protegem seu banco de dados
- Experiência do usuário: O carregamento de dados nas páginas é mais rápido e limpo
Estratégia 1: Paginação de deslocamento/limite (simples)
-- The classic approach: OFFSET and LIMIT
SELECT * FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET 40; -- page 3 (skip 40, take 20)
// Express endpoint
app.get('/products', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const products = await db.query(
'SELECT * FROM products ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset]
);
const total = await db.query('SELECT COUNT(*) FROM products');
res.json({
data: products.rows,
pagination: {
page,
limit,
total: total.rows[0].count,
totalPages: Math.ceil(total.rows[0].count / limit),
}
});
});
Prós: Simples, suporta pular para qualquer página, mostra a contagem total.
Contras: Lento em deslocamentos grandes (o banco de dados ainda verifica todas as linhas ignoradas) e os resultados podem mudar se os dados mudarem entre as solicitações.
Estratégia 2: Paginação do Cursor (Escalável)
Em vez de um deslocamento, use um “cursor” (geralmente o ID ou carimbo de data/hora do último item) para buscar a próxima página. Isso é dimensionado para milhões de linhas porque o banco de dados salta diretamente para a posição:
-- Fetch items AFTER a cursor (much faster than large OFFSET)
SELECT * FROM products
WHERE created_at < $1 -- cursor = last item's created_at
ORDER BY created_at DESC
LIMIT 20;
app.get('/products', async (req, res) => {
const limit = parseInt(req.query.limit) || 20;
const cursor = req.query.cursor; // the last item's timestamp/id
let query, params;
if (cursor) {
query = 'SELECT * FROM products WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2';
params = [cursor, limit + 1]; // fetch one extra to check for more
} else {
query = 'SELECT * FROM products ORDER BY created_at DESC LIMIT $1';
params = [limit + 1];
}
const result = await db.query(query, params);
const hasMore = result.rows.length > limit;
const items = hasMore ? result.rows.slice(0, limit) : result.rows;
const nextCursor = hasMore ? items[items.length - 1].created_at : null;
res.json({
data: items,
pagination: { nextCursor, hasMore }
});
});
Prós: Rápido em qualquer escala (sem digitalização offset), resultados estáveis mesmo quando os dados mudam.
Contras: Não é possível pular para páginas arbitrárias, sem contagem total de páginas, um pouco mais complexo.
Estratégia 3: Paginação do conjunto de chaves (melhor desempenho)
-- Keyset uses a unique, ordered column (often id) for precise positioning
SELECT * FROM products
WHERE (created_at, id) < ($1, $2) -- composite cursor handles ties
ORDER BY created_at DESC, id DESC
LIMIT 20;
A paginação do conjunto de chaves usa um cursor composto (como create_at + id) para lidar com linhas com carimbos de data/hora idênticos. É a abordagem mais robusta e de alto desempenho, usada por APIs que atendem grandes conjuntos de dados.
Comparação: qual usar
| Estratégia | Melhor para | Escala |
|---|---|---|
| Deslocamento/Limite | Conjuntos de dados pequenos, UIs administrativas que precisam de números de página | Pequeno-médio |
| Cursor | Feeds, rolagem infinita, grandes conjuntos de dados | Grande |
| Conjunto de chaves | Grandes conjuntos de dados, necessidades de alto desempenho | Muito grande |
Formato de resposta consistente
// Offset-based response
{
"data": [ /* items */ ],
"pagination": {
"page": 3,
"limit": 20,
"total": 1543,
"totalPages": 78
}
}
// Cursor-based response
{
"data": [ /* items */ ],
"pagination": {
"nextCursor": "2026-07-20T10:30:00Z",
"hasMore": true
}
}
Dicas de desempenho
- Indexe sua coluna de classificação: Consultas de paginação precisam de um índice na coluna ORDER BY ou são lentas
- Evite COUNT(*) em tabelas grandes: Contar todas as linhas é caro — a paginação do cursor evita a necessidade de uma contagem total
- Limite o limite: Imponha um tamanho máximo de página (por exemplo, 100) para que os clientes não possam solicitar tudo
- Use cursor/conjunto de chaves para dados grandes: A paginação de deslocamento degrada muito em deslocamentos grandes — o banco de dados verifica todas as linhas ignoradas
- Buscar limit+1 para detectar “hasMore”: Solicite uma linha extra para saber se há uma próxima página sem uma contagem separada
Perguntas Frequentes
P: Paginação por deslocamento ou cursor?
R: Compensação para pequenos conjuntos de dados e UIs administrativas que precisam de números de página e de pular para páginas específicas. Cursor para feeds, rolagem infinita e grandes conjuntos de dados onde o desempenho e a estabilidade são importantes. O cursor é dimensionado muito melhor, mas não pode pular para páginas arbitrárias.
P: Por que minha paginação offset está lenta?
A: OFFSET 100000 faz com que o banco de dados escaneie e descarte 100.000 linhas antes de retornar sua página – cada vez mais lento à medida que o deslocamento aumenta. Mude para a paginação do cursor ou do conjunto de chaves, que vai diretamente para a posição usando uma coluna indexada.
P: Como lidar com a alteração de dados entre páginas?
R: A paginação de deslocamento pode pular ou duplicar itens se linhas forem adicionadas/removidas entre solicitações. A paginação do cursor é estável porque ancora na posição de um item específico, não em um deslocamento numérico. Use a paginação do cursor quando os dados mudam com frequência.
P: Preciso retornar uma contagem total?
R: Para paginação offset com números de página, geralmente sim. Mas COUNT(*) em tabelas grandes é caro. A paginação do cursor evita isso completamente (apenas “hasMore”). Se você precisar de contagens aproximadas em tabelas enormes, considere contagens em cache ou estimadas.
P: Qual é um bom tamanho de página padrão e máximo?
R: 20-25 como padrão, com um máximo de 100 aplicados no lado do servidor. Isso equilibra o tamanho da resposta e o número de solicitações. Sempre limite o limite para que os clientes não possam solicitar dados ilimitados com?limit=999999.
Conclusão
A paginação adequada é essencial para qualquer API de retorno de lista. Usardeslocamento/limite para pequenos conjuntos de dados e UIs administrativas que precisam de números de página, paginação de cursor para feeds e grandes conjuntos de dados e paginação de conjunto de chaves para necessidades massivas de alto desempenho. O deslocamento é mais simples, mas degrada em deslocamentos grandes; o cursor e o conjunto de chaves são dimensionados para milhões de linhas, saltando diretamente para a posição usando colunas indexadas. Indexe sua coluna de classificação, limite o tamanho da página, busque limite+1 para detectar mais páginas e retorne um formato de resposta consistente. Escolher a estratégia certa para sua escala de dados mantém sua API rápida e seu banco de dados saudável à medida que seus dados crescem.
🔗 Share this article
✍️ Leave a Comment