Cursor Pagination é um método de carregamento paginado de dados que usa um cursor único para navegar por um conjunto ordenado de registos. De acordo com a Especificação GraphQL (2025), a paginação baseada em cursor é o padrão recomendado para APIs que trabalham com dados dinâmicos. Cursor pagination elimina as principais desvantagens da abordagem Offset: instabilidade durante inserções e degradação de desempenho em grandes offsets.
Principais pontos
Cursor Pagination é um método de carregamento paginado onde o servidor devolve juntamente com os dados um ponteiro especial — um cursor. O cliente usa este cursor no próximo pedido para obter o próximo lote de registos. O cursor é um identificador único do último elemento da página atual.
Ao contrário da paginação Offset, onde o cliente diz “dá-me a página 5 com 20 registos,” a paginação por cursor funciona de outra forma: “dá-me 20 registos após o registo com ID = 83.” O servidor executa uma consulta com WHERE id > 83 e LIMIT 20. Esta abordagem garante que cada registo cai exatamente numa página independentemente das inserções.
O conceito de paginação por cursor ganhou ampla adoção graças à especificação Relay Connection (GraphQL), que tornou a paginação baseada em cursor o padrão para APIs modernas. O Relay define o formato de resposta: edges (array de registos com cursores), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
A paginação por cursor não é uma técnica nova — era usada em bases de dados muito antes da web. Em SQL chama-se keyset pagination ou seek method. O método tornou-se popular em APIs após a publicação da especificação Relay em 2015, que formalizou o formato do cursor como uma string codificada em base64 para uniformidade no transporte HTTP.
O princípio básico da paginação por cursor é que a consulta usa uma condição WHERE num campo indexado para posicionamento, não um offset. Para direção para a frente usa-se WHERE id > last_id; para direção para trás, WHERE id < first_id. O índice B-tree encontra o primeiro registo após o cursor em O(log n), proporcionando um tempo de resposta estável.
-- Obter 20 registros após o cursor '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Obter 20 registros ANTES do cursor '83' (para trás)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Um cursor pode ser simples (um valor ID) ou complexo (composto por vários campos). Os cursores simples são a chave primária de um registo, por exemplo, id auto-incrementado ou UUID. Os cursores compostos são usados para ordenar por campos não únicos, por exemplo (created_at, id), onde id garante a unicidade quando os timestamps são idênticos.
Um formato típico de API é um cursor como string codificada em base64. O servidor descodifica o cursor, extrai o valor e constrói a consulta SQL. A codificação Base64 oculta a estrutura interna do cursor do cliente e permite alterar o formato sem quebrar a compatibilidade retroativa. O cliente recebe os cursores no campo endCursor da resposta e passa-os como string no próximo pedido.
A paginação por cursor suporta navegação bidirecional. Para movimento para a frente (next), usa-se o cursor do último elemento da página atual; para trás (previous), o cursor do primeiro elemento. Os parâmetros after e before no pedido determinam a direção: after obtém registos após o cursor, before obtém registos antes do cursor.
A escolha entre paginação por cursor e Offset é uma das decisões arquitetónicas chave ao projetar uma API. Cada método tem pontos fortes e fracos que determinam a sua aplicabilidade. Cursor pagination vence em cenários com dados dinâmicos; Offset vence em cenários com navegação arbitrária.
| Característica | Cursor | Offset |
|---|---|---|
| Estabilidade em inserções | Alta (sem duplicados) | Baixa (deslocamento de páginas) |
| Desempenho em grandes conjuntos | O(log n) — estável | O(n) — degrada com o crescimento |
| Navegação por número de página | Não | Sim (page=5) |
| Complexidade de implementação | Média | Baixa |
| Suporte REST | cursor/before/after | page/offset |
| Suporte GraphQL | Padrão Relay | Não recomendado |
A paginação Offset realiza uma varredura completa da tabela até à posição OFFSET. Com offset=100000, a base de dados lê e salta 100000 linhas, mesmo que LIMIT seja 20. MySQL e PostgreSQL não conseguem otimizar OFFSET — é uma característica de implementação de LIMIT/OFFSET em SQL. A paginação por cursor usa um índice B-tree que encontra a posição em O(log n).
Um problema adicional do Offset é “saltar” registos ao paginar para trás. Se um utilizador carregou a página 5 e nesse momento novos registos foram adicionados, ao solicitar a página 6 ele verá novamente o registo da página 5 ou perderá os novos. Cursor pagination elimina completamente este cenário: o cursor aponta para um local específico no conjunto e as inserções não alteram a posição.
Vejamos a implementação da paginação por cursor no backend (Kotlin + Spring) e no cliente (Android + Retrofit). O servidor aceita os parâmetros after, before, limit e devolve uma lista de registos com cursores e pageInfo. Uma resposta típica contém hasNextPage e hasPreviousPage para gerir a UI de paginação.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
No cliente, a paginação por cursor é implementada através de PagingSource do Paging 3, onde a chave é um cursor (Long). PagingSource.load recebe LoadParams.key — o cursor do último registo carregado. LoadResult.Page devolve os dados e nextKey — o cursor para a próxima página. Quando nextKey = null, a paginação está completa.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource com chave de cursor
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
Em GraphQL, a paginação por cursor é implementada através do padrão Relay Connection. Cada tipo tem uma Connection (com pageInfo e edges) e uma Edge (node + cursor). A consulta passa os parâmetros first, after, last, before. O servidor devolve um array de edges com cursores e pageInfo com hasNextPage/hasPreviousPage.
A paginação por cursor é recomendada para APIs que trabalham com dados dinâmicos onde os registos são frequentemente adicionados ou removidos. Exemplos clássicos: feed de notícias numa rede social, mensagens de chat, histórico de transações, comentários em publicações. Em todos estes cenários, a consistência e a ausência de duplicados são importantes.
Existem cenários onde a paginação Offset é mais conveniente: painéis de administração onde é necessária navegação por número de página; pesquisa com paginação onde os resultados podem mudar; relatórios e análises onde é necessário um link fixo para a página 5. Nestes casos, as vantagens do cursor não superam a complexidade de implementação.
A paginação por cursor não suporta “saltar” para uma página arbitrária — o utilizador não pode clicar em “Página 5” e ir para lá. Esta é uma limitação arquitetónica: contar o número total de páginas requer uma consulta COUNT separada, que pode ser cara para tabelas grandes. Nesses casos, uma abordagem híbrida: cursor para dados + count para paginação.
Perguntas frequentes
Um cursor é um identificador único de registo que aponta para uma posição no conjunto de dados. Pode ser simples (um ID de registo) ou composto (vários campos). O cliente recebe o cursor do último registo da página e passa-o no próximo pedido para obter o próximo lote.
Cursor pagination não está sujeito a deslocamento quando novos registos são adicionados — cada elemento cai exatamente numa página. Também mantém a velocidade em grandes volumes usando índices em vez de varrer as primeiras n linhas. Offset é mais simples mas instável para dados dinâmicos.
Sim, a paginação por cursor não está vinculada ao GraphQL. Pode ser implementada em qualquer API REST passando o cursor como parâmetro de consulta ?after=83&limit=20. A resposta deve conter pageInfo com endCursor e hasNextPage — isto permite ao cliente gerir o carregamento sem conhecer a estrutura interna do cursor.
ID auto-incrementado é a escolha ideal: aumenta monotonicamente, não muda, é indexado eficientemente. UUID v7 (ordenado por tempo) também funciona. Timestamps podem produzir duplicados ao mesmo tempo, por isso combine-o com ID: (created_at, id) para garantir a unicidade do cursor.
A paginação por cursor não fornece o número total de páginas — esta é a sua limitação. Se precisar de informação total, execute uma consulta COUNT separada com os mesmos filtros. Para tabelas grandes, use contagem aproximada através de EXPLAIN ou um total em cache de análises.
Resumo
Vamos desenvolver um aplicativo móvel chave na mão
A IT Sectr cria aplicativos para iOS e Android para startups e empresas desde 2017. Nós vamos aconselhá-lo e propor a melhor solução.
Leia também