DEV Community

Cover image for Paginación por cursor vs. paginación por desplazamiento: ¿Cuál usar en tu API?
Roobia
Roobia

Posted on Originally published at apidog.com

Paginación por cursor vs. paginación por desplazamiento: ¿Cuál usar en tu API?

Paginación por offset vs. cursor: cómo elegir y probar cada estrategia

Cada endpoint de lista acaba enfrentándose a la misma pregunta: ¿cómo dividir 2 millones de pedidos en páginas navegables? La paginación por desplazamiento (offset) ofrece SQL sencillo y números de página; la paginación basada en cursor ofrece resultados estables y latencia consistente, pero no permite saltar a la página 47.

Prueba Apidog hoy

La mayoría de los equipos empieza con offset porque es el patrón habitual en los tutoriales. El problema aparece en producción: la página 4.000 empieza a agotar el tiempo de espera y los usuarios ven registros repetidos mientras se desplazan.

Esta guía explica cómo funcionan ambos estilos, dónde falla offset, por qué Stripe y Slack usan cursores y cómo probar recorridos completos con solicitudes encadenadas en Apidog. Para una comparación más amplia, consulta esta guía de paginación de API.

Paginación por desplazamiento (offset)

Este enfoque se traduce directamente a SQL. El cliente envía un número y un tamaño de página; el servidor los convierte en LIMIT y OFFSET:

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Enter fullscreen mode Exit fullscreen mode

La consulta devuelve la página 3, con 25 filas por página:

GET /v1/orders?page=3&per_page=25
Enter fullscreen mode Exit fullscreen mode

Respuesta típica:

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}
Enter fullscreen mode Exit fullscreen mode

Sus ventajas son claras:

  • Permite saltar a cualquier página.
  • Puede devolver el recuento total.
  • Es rápido de implementar.
  • Funciona bien para tablas administrativas pequeñas.

La guía paso a paso de paginación en APIs REST muestra una implementación completa.

Sin embargo, offset tiene dos problemas estructurales.

1. Deriva de página

OFFSET cuenta filas desde el principio del resultado ordenado; no sabe qué filas ya vio el cliente. Si se insertan o eliminan registros entre dos solicitudes, las páginas se desplazan.

Ejemplo:

  1. El usuario carga la página 1, filas 1 a 25.
  2. Se insertan 3 pedidos nuevos.
  3. Solicita la página 2 (OFFSET 25).
  4. Las filas 23, 24 y 25 de la primera respuesta ahora ocupan las posiciones 26, 27 y 28.

Resultado: el usuario vuelve a verlas.

Las eliminaciones provocan el efecto contrario: si se eliminan 3 filas de la página 1, OFFSET 25 salta 3 registros que el usuario nunca recibió. Es una pérdida silenciosa de datos.

Para un informe mensual estático, esto puede ser irrelevante. Para un feed de actividad, un endpoint de sincronización o cualquier script que recorra páginas mientras continúan las escrituras, produce duplicados y registros faltantes.

2. Los desplazamientos profundos escanean lo que omiten

OFFSET 500000 no salta directamente a la fila 500.001. La base de datos recorre el índice, descarta medio millón de entradas y después devuelve las 25 filas solicitadas. El costo crece linealmente con la profundidad: O(n).

En una tabla de pedidos de PostgreSQL con 2 millones de filas e índice en created_at:

  • LIMIT 25 OFFSET 0 lee 25 entradas de índice: unos pocos milisegundos.
  • LIMIT 25 OFFSET 100000 lee 100.025 entradas y descarta 100.000: decenas de milisegundos.
  • LIMIT 25 OFFSET 1500000 lee 1,5 millones de entradas: cientos de milisegundos, más búferes y CPU para una sola página.

El artículo de Markus Winand sobre “no-offset” demuestra este costo con planes de consulta. En producción, las solicitudes con desplazamientos altos suelen dominar el registro de consultas lentas; a veces las genera un rastreador que recorre todas las páginas públicas de la API.

Paginación basada en cursor

También llamada paginación de conjunto de claves (keyset pagination), elimina el contador de filas. En lugar de pedir “las 25 filas después de saltar 50”, el cliente pide “las filas posteriores a este registro”.

El SQL usa una comparación sobre la clave de ordenación:

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Enter fullscreen mode Exit fullscreen mode

La comparación usa dos columnas porque created_at no es única. Dos pedidos pueden crearse en el mismo milisegundo. Añadir id como desempate crea un orden total y evita omisiones o duplicados en los límites de página.

Con un índice compuesto en (created_at, id), la base de datos busca directamente el límite y lee 25 entradas. La primera página y la página 60.000 tienen un costo similar.

La API no debería exponer los valores internos. Codifica la clave en un token opaco, normalmente en Base64:

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0=
Enter fullscreen mode Exit fullscreen mode

La opacidad es una decisión de diseño, no una medida de seguridad por sí misma. Si los clientes no pueden interpretar el cursor, puedes cambiar la clave de ordenación, añadir información de fragmentación o migrar el almacenamiento sin romper sus URLs. El contrato es simple: “devuélveme lo que te entregamos”.

La desventaja es que no existe la página 47. Un cursor solo conoce la posición posterior a una fila, así que el cliente avanza —o retrocede, si proporcionas un cursor anterior— de una página en una. Los recuentos totales también requieren una consulta separada. Para conjuntos grandes, consulta cómo diseñar la paginación de API para millones de registros.

Comparación rápida

Dimensión Offset Cursor
Saltar a una página arbitraria No, solo recorrido secuencial
Recuento total y de páginas Barato de incluir Requiere una consulta separada
Rendimiento en páginas profundas O(n), empeora con la profundidad O(1) por página
Estabilidad ante escrituras Puede producir duplicados y huecos Estable, anclada a una fila
Costo de implementación Bajo Moderado: tokens, desempates e índices
Requisitos de ordenación Cualquier ORDER BY Clave única e indexada
Caché de URL Sencillo, URLs predecibles Más difícil, el cursor depende del recorrido
Complejidad del cliente Baja Baja si la envoltura es clara

La condición importante es que la paginación por cursor necesita una ordenación determinista. Si permites ordenar por una columna mutable y no única, como status, el conjunto de claves se vuelve difícil de mantener. Offset tolera una ordenación menos rigurosa; los cursores no.

¿Cuál deberías elegir?

Elige según la forma en que se consumen los datos:

Tablas administrativas y dashboards: offset

Úsalo para herramientas internas con pocos miles de filas, usuarios que hacen clic en números de página y un recuento visible como “1.848 resultados”. La deriva suele ser aceptable, la profundidad es limitada y saltar a una página es una función útil.

Feeds de desplazamiento infinito: cursor

Nadie necesita saltar a la página 47 de un feed. Los usuarios cargan “más”, las escrituras son constantes y los duplicados son visibles. Es el caso de uso clásico para cursores.

APIs públicas: cursor

No controlas a tus consumidores. Alguien creará un bucle que recorra todas las páginas y, con offset, las páginas profundas se convierten en un problema operativo. Los cursores mantienen estable el costo y permiten evolucionar los detalles internos detrás de un token opaco. Consulta la guía de paginación de API REST para convenciones de URLs y encabezados.

Exportaciones y sincronizaciones: cursor

Un trabajo que extrae 2 millones de pedidos necesita:

  1. No omitir filas pese a las escrituras concurrentes.
  2. Mantener un costo constante por página.

Offset no garantiza ninguna de las dos. Además, el cursor funciona como punto de reanudación si el trabajo falla en la fila 1,4 millones.

Regla práctica: usa offset para interfaces pequeñas, navegadas por humanos y con muchos recuentos; usa cursores para datos grandes, activos o públicos.

Cómo lo implementan las APIs reales

  • Stripe: todos sus endpoints de lista usan cursores. Aceptan starting_after —un ID de objeto— y limit, y devuelven has_more. La documentación de paginación de Stripe muestra el patrón. No incluye un recuento total, una decisión coherente con su volumen de escrituras.
  • GitHub: la mayoría de sus endpoints REST todavía exponen page y per_page, con encabezados Link para las páginas siguiente y última. Su documentación de paginación recomienda seguir el encabezado literalmente en lugar de construir las URLs. Los endpoints más nuevos usan cursores porque los recorridos profundos resultan costosos en repositorios grandes.
  • Slack: migró su API web a cursores y recomienda este enfoque para los métodos nuevos. conversations.history, por ejemplo, devuelve response_metadata.next_cursor. Un cursor vacío indica el final, como explica la documentación de paginación de Slack.

La dirección general en estas APIs de alto tráfico es clara: pasar de desplazamientos a cursores.

Diseña una envoltura de respuesta predecible

Una respuesta de cursor puede tener esta forma:

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Enter fullscreen mode Exit fullscreen mode

Sigue estas reglas:

  1. Devuelve siempre has_more. Los clientes no deben inferir el final a partir de una página corta; el filtrado posterior a la recuperación puede reducir su tamaño.
  2. Devuelve next_cursor: null en la última página y documenta la convención. También puedes usar una cadena vacía, como Slack, pero no mezcles ambos estilos.
  3. Rechaza cursores inválidos con 400, no con una respuesta 200 vacía. Devuelve un código de error legible por máquina.
  4. Firma o versiona el payload si contiene algo más que las claves de ordenación. Esto facilita futuras migraciones de esquema.

Prueba ambos estilos en Apidog

Los errores de paginación aparecen en los límites: última página, página vacía o cursor cuyo registro ancla fue eliminado. Las pruebas manuales suelen omitirlos; un escenario encadenado puede verificarlos automáticamente.

Para un endpoint basado en cursor:

  1. Extrae el cursor inicial. Añade un postprocesador a la primera solicitud, usa el JSONPath $.next_cursor y guárdalo en una variable como nextCursor. Consulta cómo establecer aserciones y extraer variables con JSONPath.
  2. Repite la solicitud siguiente. Usa {{nextCursor}} como parámetro, vuelve a extraer $.next_cursor en cada iteración y detén el bucle cuando has_more sea false.
  3. Valida cada página. Asegura que ningún id se repita respecto a la página anterior y que el tamaño nunca supere limit.

Para endpoints con offset, usa una variable contador:

  • Incrementa page.
  • Verifica que data.length sea igual a per_page hasta la última página.
  • Comprueba que total se mantenga consistente durante el recorrido.

Añade también estos casos límite:

  • Página vacía: usa un filtro sin coincidencias y comprueba data: [], has_more: false y estado 200.
  • Cursor inválido: envía cursor=not-a-real-cursor y verifica estado 400 junto con un código de error legible por máquina.
  • Fila ancla eliminada: crea un pedido, obtiene un cursor anclado a él, elimina el pedido y usa el cursor. El recorrido debe continuar desde la posición correcta. La comparación de conjunto de claves lo permite aunque la fila ancla ya no exista.

Cuando el escenario pase localmente, ejecútalo en CI con cada fusión. Puedes descargar Apidog y configurar el recorrido completo —incluidos bucles y aserciones— en menos de media hora.

Preguntas frecuentes

¿La paginación por cursor siempre es mejor?

No. Offset es preferible cuando los usuarios necesitan números de página, totales y acceso aleatorio a un conjunto de datos pequeño o moderado. Los cursores son mejores para conjuntos grandes, escrituras frecuentes y APIs públicas.

El error habitual es elegir offset para un endpoint público y descubrir después del lanzamiento su costo O(n) en páginas profundas.

¿Cómo obtengo un recuento total con cursores?

Ejecuta un SELECT COUNT(*) separado con los mismos filtros. Puedes exponerlo mediante otro endpoint o un parámetro opcional como include_count=true. Almacénalo agresivamente en caché; para casi todas las interfaces basta con un recuento aproximado actualizado cada minuto.

Stripe omite los totales por completo, lo que demuestra que muchos consumidores no los necesitan.

¿Puedo ofrecer ambos estilos en un solo endpoint?

Sí; GitHub lo hace durante su transición. Para una API nueva, suele ser mejor elegir un único estilo por endpoint. Ofrecer ambos implica dos conjuntos de casos límite, dos matrices de pruebas y más confusión para los clientes.

Si diseñas el contrato desde cero, usa las convenciones de la guía de paginación de API REST para mantener consistentes los nombres de parámetros.

¿Qué ocurre si se elimina la fila ancla?

Nada se rompe con la paginación de conjunto de claves. La comparación:

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

no necesita que la fila ancla exista; busca la posición del límite y continúa. Esta es una ventaja importante frente a los diseños que tratan el cursor como una búsqueda directa de la fila. Verifica este caso en tu escenario de pruebas de Apidog antes de que lo encuentre un consumidor.

Top comments (0)