Como implementar pagination eficiente em APIs REST com cursor
1. Fundamentos da Paginação por Cursor
A paginação baseada em offset, tradicionalmente usada com LIMIT e OFFSET, apresenta problemas críticos em APIs REST que lidam com grandes volumes de dados. Quando novos registros são inseridos entre requisições, o offset se desloca, causando duplicação ou omissão de itens. Além disso, em bancos com milhões de registros, o OFFSET exige que o banco leia e descarte linhas até a posição desejada, degradando severamente a performance.
O cursor resolve esses problemas: é um identificador opaco que referencia uma posição específica no conjunto de resultados. Em vez de "pule 100 itens", o cursor diz "comece após este item específico". Isso garante consistência mesmo com inserções concorrentes, pois o cursor aponta para um registro imutável, não para uma posição numérica volátil.
Comparação prática:
| Característica | Offset-based | Cursor-based |
|---|---|---|
| Consistência | Falha com inserções | Mantém consistência |
| Performance | Degrada com OFFSET alto | Constante (usa índices) |
| Navegação reversa | Simples | Requer cursor bidirecional |
| Conhecimento total | Fácil (COUNT) | Difícil (aproximado) |
2. Estrutura de Dados e Design do Cursor
O campo escolhido como cursor deve ser único, ordenável e imutável. UUIDs, timestamps precisos e IDs incrementais são ideais. Evite campos que podem ser atualizados, como updated_at.
Para serialização, o cursor geralmente é codificado em base64 para ser seguro em URLs:
// Exemplo de cursor JSON codificado
cursor = base64_encode(JSON.stringify({
"type": "created_at",
"value": "2024-03-15T10:30:00Z",
"id": "abc123"
}))
Cursos compostos são necessários quando a ordenação não é por um campo único. Por exemplo, ao ordenar por created_at, múltiplos registros podem ter o mesmo timestamp. Adicionar o ID como segundo campo garante unicidade:
// Query com cursor composto
SELECT * FROM posts
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT $3
3. Implementação de Consultas com Cursor
A construção de queries com cursor usa operadores de comparação diretos, aproveitando índices compostos:
Exemplo em banco relacional (PostgreSQL):
-- Índice composto necessário
CREATE INDEX idx_posts_created_id ON posts(created_at, id);
-- Query paginada forward
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) > ('2024-03-15T10:30:00Z', 'abc123')
ORDER BY created_at ASC, id ASC
LIMIT 20;
-- Query paginada backward
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2024-03-15T10:30:00Z', 'abc123')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Exemplo em banco NoSQL (MongoDB):
db.posts.find({
$or: [
{ created_at: { $gt: ISODate("2024-03-15T10:30:00Z") } },
{ created_at: ISODate("2024-03-15T10:30:00Z"), _id: { $gt: ObjectId("abc123") } }
]
}).sort({ created_at: 1, _id: 1 }).limit(20)
4. Controle de Direção e Tamanho da Página
A API deve aceitar três parâmetros principais:
GET /api/posts?cursor=xxx&limit=20&direction=forward
cursor: string codificada representando a posição atuallimit: número máximo de itens (com validação de máximo, ex: 100)direction: "forward" (padrão) ou "backward"
A resposta deve incluir ambos os cursores para navegação bidirecional:
{
"data": [...],
"next_cursor": "base64...",
"previous_cursor": "base64...",
"limit": 20,
"has_next": true,
"has_previous": false
}
Para gerar o previous_cursor, execute a query reversa com o primeiro item da página atual. O next_cursor usa o último item.
5. Tratamento de Casos de Borda e Consistência
Quando dados são inseridos durante a paginação, o cursor fixo mantém a consistência: o usuário vê um snapshot estável do momento em que iniciou a navegação. Itens novos aparecerão apenas se o usuário recomeçar a paginação do início.
Para detectar o fim da lista, verifique se o número de resultados retornados é menor que o limit solicitado. Se for, não há mais páginas naquela direção:
// Lógica de detecção de fim
has_next = (resultados.length === limit)
has_previous = (cursor_inicial não é nulo)
Em cenários de tempo real, timestamps com precisão de microssegundos ou UUIDs ordenáveis (como ULID) minimizam colisões e garantem ordenação consistente mesmo sob alta concorrência.
6. Resposta da API e Metadados
A estrutura padrão de resposta deve ser previsível e auto-descritiva:
HTTP/1.1 200 OK
Content-Type: application/json
X-Cursor-Next: base64...
X-Cursor-Previous: base64...
X-Estimated-Total: 15420
{
"data": [
{ "id": "abc123", "title": "Post 1", "created_at": "2024-03-15T10:30:00Z" },
{ "id": "def456", "title": "Post 2", "created_at": "2024-03-15T10:31:00Z" }
],
"pagination": {
"next_cursor": "base64...",
"previous_cursor": null,
"limit": 20,
"has_next": true,
"has_previous": false,
"estimated_total": 15420
},
"meta": {
"processing_time_ms": 12,
"cursor_version": "v1"
}
}
O total estimado pode ser obtido via consulta aproximada (EXPLAIN no PostgreSQL) ou cache separado, evitando COUNT pesado.
7. Estratégias de Cache e Performance
Cache com Redis: Armazene resultados paginados por cursor com TTL curto (30-60s). A chave pode ser o hash do cursor + parâmetros:
// Estrutura de cache
cache_key = "posts:cursor:" + md5(cursor + "|" + limit + "|" + direction)
cache.setex(cache_key, 30, JSON.stringify(resultados))
Paginação assíncrona: Para consultas muito pesadas (milhões de registros), gere cursors em background usando filas (RabbitMQ, SQS). O usuário recebe um ticket e consulta o resultado posteriormente.
Materialized Views: Para consultas paginadas frequentes com filtros complexos, crie materialized views atualizadas periodicamente. Os índices na view garantem performance constante:
CREATE MATERIALIZED VIEW mv_posts_recent AS
SELECT id, title, created_at
FROM posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC;
CREATE INDEX idx_mv_posts_created_id ON mv_posts_recent(created_at, id);
Essa abordagem transforma consultas que levariam segundos em respostas de milissegundos, ideal para APIs de alto tráfego.
Referências
- Cursor-Based Pagination no PostgreSQL — Guia prático sobre implementação de paginação com cursores em PostgreSQL, incluindo índices compostos e operadores de comparação.
- Documentação Oficial do MongoDB sobre Paginação — Explicação detalhada sobre paginação baseada em cursor no MongoDB, com exemplos de queries e índices.
- REST API Pagination Strategies by Shopify — Documentação oficial da Shopify sobre implementação de paginação por cursor em APIs REST, incluindo headers e metadados.
- Twitter API v2 Pagination — Exemplo real de paginação por cursor em grande escala, com tratamento de direção e limites.
- Cursor Pagination Explained by Stripe — Documentação da Stripe sobre paginação baseada em cursor, incluindo serialização e boas práticas de design de API.
- Implementing Cursor Pagination in Node.js — Tutorial prático com código Node.js/Express mostrando implementação completa de paginação por cursor.
- GraphQL Connections Specification — Especificação oficial do Relay para paginação por cursor em GraphQL, que inspirou muitas implementações REST modernas.