Cursor Pagination es un método de carga paginada de datos que utiliza un cursor único para navegar por un conjunto ordenado de registros. Según la Especificación GraphQL (2025), la paginación basada en cursor es el estándar recomendado para APIs que trabajan con datos dinámicos. Cursor pagination elimina los principales inconvenientes del enfoque Offset: inestabilidad durante las inserciones y degradación del rendimiento en grandes desplazamientos.
Puntos clave
Cursor Pagination es un método de carga paginada donde el servidor devuelve junto con los datos un puntero especial — un cursor. El cliente usa este cursor en la siguiente solicitud para obtener el siguiente lote de registros. El cursor es un identificador único del último elemento de la página actual.
A diferencia de la paginación Offset, donde el cliente dice “dame la página 5 con 20 registros,” la paginación por cursor funciona de otra manera: “dame 20 registros después del registro con ID = 83.” El servidor ejecuta una consulta con WHERE id > 83 y LIMIT 20. Este enfoque garantiza que cada registro caiga exactamente en una página independientemente de las inserciones.
El concepto de paginación por cursor ganó gran aceptación gracias a la especificación Relay Connection (GraphQL), que convirtió la paginación basada en cursor en el estándar para APIs modernas. Relay define el formato de respuesta: edges (array de registros con cursores), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
La paginación por cursor no es una técnica nueva — se usaba en bases de datos mucho antes de la web. En SQL se llama keyset pagination o seek method. El método se popularizó en APIs tras la publicación de la especificación Relay en 2015, que formalizó el formato del cursor como una cadena codificada en base64 para uniformidad en la transmisión HTTP.
El principio básico de la paginación por cursor es que la consulta usa una condición WHERE sobre un campo indexado para el posicionamiento, no un desplazamiento. Para dirección hacia adelante se usa WHERE id > last_id; para dirección hacia atrás, WHERE id < first_id. El índice B-tree encuentra el primer registro después del cursor en O(log n), proporcionando un tiempo de respuesta estable.
-- Obtener 20 registros después del cursor '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Obtener 20 registros ANTES del cursor '83' (atrás)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Un cursor puede ser simple (un valor ID) o complejo (compuesto por varios campos). Los cursores simples son la clave primaria de un registro, por ejemplo, id autoincremental o UUID. Los cursores compuestos se usan para ordenar por campos no únicos, por ejemplo (created_at, id), donde id garantiza la unicidad cuando las marcas de tiempo son idénticas.
Un formato típico de API es un cursor como cadena codificada en base64. El servidor decodifica el cursor, extrae el valor y construye la consulta SQL. La codificación Base64 oculta la estructura interna del cursor al cliente y permite cambiar el formato sin romper la compatibilidad hacia atrás. El cliente recibe los cursores en el campo endCursor de la respuesta y los pasa como cadena en la siguiente solicitud.
La paginación por cursor admite navegación bidireccional. Para el movimiento hacia adelante (next), se usa el cursor del último elemento de la página actual; para hacia atrás (previous), el cursor del primer elemento. Los parámetros after y before en la solicitud determinan la dirección: after toma registros después del cursor, before toma registros antes del cursor.
La elección entre paginación por cursor y Offset es una de las decisiones arquitectónicas clave al diseñar una API. Cada método tiene fortalezas y debilidades que determinan su aplicabilidad. Cursor pagination gana en escenarios con datos dinámicos; Offset gana en escenarios con navegación arbitraria.
| Característica | Cursor | Offset |
|---|---|---|
| Estabilidad en inserciones | Alta (sin duplicados) | Baja (desplazamiento de páginas) |
| Rendimiento en grandes conjuntos | O(log n) — estable | O(n) — degrada con el crecimiento |
| Navegación por número de página | No | Sí (page=5) |
| Complejidad de implementación | Media | Baja |
| Soporte REST | cursor/before/after | page/offset |
| Soporte GraphQL | Estándar Relay | No recomendado |
La paginación Offset realiza un escaneo completo de la tabla hasta la posición OFFSET. Con offset=100000, la base de datos lee y salta 100000 filas, incluso si LIMIT es 20. MySQL y PostgreSQL no pueden optimizar OFFSET — es una característica de implementación de LIMIT/OFFSET en SQL. La paginación por cursor usa un índice B-tree que encuentra la posición en O(log n).
Un problema adicional de Offset es el “salteo” de registros al paginar hacia atrás. Si un usuario cargó la página 5 y en ese momento se añadieron nuevos registros, al solicitar la página 6 verá de nuevo el registro de la página 5 o se perderá los nuevos. Cursor pagination elimina completamente este escenario: el cursor apunta a un lugar específico en el conjunto y las inserciones no cambian la posición.
Veamos la implementación de la paginación por cursor en el backend (Kotlin + Spring) y en el cliente (Android + Retrofit). El servidor acepta los parámetros after, before, limit y devuelve una lista de registros con cursores y pageInfo. Una respuesta típica contiene hasNextPage y hasPreviousPage para gestionar la UI de paginación.
@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
)
)
}
En el cliente, la paginación por cursor se implementa mediante PagingSource de Paging 3, donde la clave es un cursor (Long). PagingSource.load recibe LoadParams.key — el cursor del último registro cargado. LoadResult.Page devuelve los datos y nextKey — el cursor para la siguiente página. Cuando nextKey = null, la paginación está completa.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource with cursor key
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)
}
}
En GraphQL, la paginación por cursor se implementa mediante el patrón Relay Connection. Cada tipo tiene una Connection (con pageInfo y edges) y un Edge (node + cursor). La consulta pasa los parámetros first, after, last, before. El servidor devuelve un array de edges con cursores y pageInfo con hasNextPage/hasPreviousPage.
Se recomienda la paginación por cursor para APIs que trabajan con datos dinámicos donde los registros se añaden o eliminan con frecuencia. Ejemplos clásicos: un feed de noticias en una red social, mensajes de chat, historial de transacciones, comentarios de publicaciones. En todos estos escenarios, la consistencia y la ausencia de duplicados son importantes.
Existen escenarios donde la paginación Offset es más conveniente: paneles de administración donde se necesita navegación por número de página; búsqueda con paginación donde los resultados pueden cambiar; informes y análisis donde se necesita un enlace fijo a la página 5. En estos casos, las ventajas del cursor no superan la complejidad de implementación.
La paginación por cursor no admite “saltar” a una página arbitraria — el usuario no puede hacer clic en “Página 5” e ir allí. Esta es una limitación arquitectónica: contar el número total de páginas requiere una consulta COUNT separada, que puede ser costosa para tablas grandes. En tales casos, un enfoque híbrido: cursor para datos + count para paginación.
Preguntas frecuentes
Un cursor es un identificador único de registro que señala una posición en el conjunto de datos. Puede ser simple (un ID de registro) o compuesto (varios campos). El cliente recibe el cursor del último registro de la página y lo pasa en la siguiente solicitud para obtener el siguiente lote.
Cursor pagination no está sujeto a desplazamiento cuando se añaden nuevos registros — cada elemento cae exactamente en una página. También mantiene la velocidad en grandes volúmenes usando índices en lugar de escanear las primeras n filas. Offset es más simple pero inestable para datos dinámicos.
Sí, la paginación por cursor no está vinculada a GraphQL. Se puede implementar en cualquier API REST pasando el cursor como parámetro de consulta ?after=83&limit=20. La respuesta debe contener pageInfo con endCursor y hasNextPage — esto permite al cliente gestionar la carga sin conocer la estructura interna del cursor.
ID autoincremental es la opción óptima: aumenta monótonamente, no cambia, se indexa eficientemente. UUID v7 (ordenado por tiempo) también funciona. Los timestamps pueden producir duplicados en el mismo momento, así que combínalo con ID: (created_at, id) para garantizar la unicidad del cursor.
La paginación por cursor no proporciona el número total de páginas — esta es su limitación. Si necesitas información total, ejecuta una consulta COUNT separada con los mismos filtros. Para tablas grandes, usa un conteo aproximado mediante EXPLAIN o un total almacenado en caché desde analíticas.
Resumen
Desarrollaremos una aplicación móvil llave en mano
IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.
Lea también