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 atual
  • limit: 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