Paginación por cursor vs paginación por offset para APIs rápidas de pantallas de administración
Aprende la diferencia entre paginación por cursor y por offset y cómo diseñar un contrato de API consistente para orden, filtros y totales que mantenga rápidas las pantallas de administración en web y móvil.

Por qué la paginación puede hacer que las pantallas de administración se sientan lentas
Las pantallas de administración suelen empezar como una tabla simple: carga las primeras 25 filas, añade un cuadro de búsqueda y listo. Con unos cientos de registros se siente instantáneo. Luego el conjunto de datos crece y la misma pantalla empieza a tambalearse.
El problema habitual no es la UI. Es lo que la API tiene que hacer antes de poder devolver la página 12 con ordenación y filtros aplicados. A medida que la tabla crece, el backend pasa más tiempo encontrando coincidencias, contándolas y saltándose resultados anteriores. Si cada clic desencadena una consulta más pesada, la pantalla parece que está pensando en vez de responder.
Sueles notarlo en los mismos puntos: los cambios de página se vuelven más lentos con el tiempo, la ordenación se vuelve torpe, la búsqueda se siente inconsistente entre páginas y el scroll infinito carga a ráfagas (rápido y luego de repente lento). En sistemas cargados incluso puedes ver duplicados o filas faltantes cuando los datos cambian entre peticiones.
Las UIs web y móviles también empujan la paginación en direcciones distintas. Una tabla administrativa web incentiva saltar a una página concreta y ordenar por muchas columnas. Las pantallas móviles suelen usar una lista infinita que carga el siguiente bloque, y los usuarios esperan que cada carga sea igual de rápida. Si tu API se construye solo alrededor de números de página, el móvil suele sufrir. Si se construye solo en next/after, las tablas web pueden sentirse limitadas.
El objetivo no es solo devolver 25 ítems. Es una paginación rápida y predecible que se mantenga estable a medida que los datos crecen, con reglas que funcionen igual para tablas y listas infinitas.
Conceptos básicos de paginación de los que depende tu UI
La paginación divide una lista larga en trozos más pequeños para que la pantalla pueda cargar y renderizar rápido. En lugar de pedir todos los registros, la UI solicita el siguiente segmento de resultados.
El control más importante es el tamaño de página (a menudo llamado limit). Páginas más pequeñas suelen parecer más rápidas porque el servidor hace menos trabajo y la app dibuja menos filas. Pero páginas demasiado pequeñas pueden resultar entrecortadas porque los usuarios tienen que hacer más clics o desplazamientos. Para muchas tablas administrativas, de 25 a 100 ítems es un rango práctico, con móvil prefiriendo el extremo inferior.
Un orden estable importa más de lo que la mayoría de equipos espera. Si el orden puede cambiar entre peticiones, los usuarios verán duplicados o filas faltantes al paginar. Un orden estable suele significar ordenar por un campo principal (como created_at) más un desempate (como id). Esto importa tanto si usas paginación por offset como por cursor.
Desde el punto de vista del cliente, una respuesta paginada debería incluir los ítems, una pista para la siguiente página (número de página o token de cursor) y solo los conteos que la UI realmente necesita. Algunas pantallas necesitan un total exacto para “1-50 de 12,340”. Otras solo necesitan has_more.
Paginación por offset: cómo funciona y dónde duele
La paginación por offset es el clásico enfoque página N. El cliente pide un número fijo de filas y le dice a la API cuántas filas saltarse primero. Lo verás como limit y offset, o como page y pageSize que el servidor convierte en un offset.
Una petición típica se ve así:
GET /tickets?limit=50&offset=950- “Dame 50 tickets, saltándote los primeros 950.”
Coincide con las necesidades administrativas comunes: ir a la página 20, examinar registros antiguos o exportar una lista grande en trozos. También es fácil de explicar internamente: “Mira la página 3 y verás eso.”
El problema aparece en páginas profundas. Muchas bases de datos todavía tienen que recorrer las filas saltadas antes de devolver tu página, especialmente cuando el orden no está respaldado por un índice ajustado. La página 1 puede ser rápida, pero la página 200 puede volverse notablemente más lenta, que es exactamente lo que hace que las pantallas de administración se sientan lentas cuando los usuarios se desplazan o saltan.
El otro problema es la consistencia cuando los datos cambian. Imagínate que un gestor de soporte abre la página 5 de tickets ordenada por los más recientes primero. Mientras mira, llegan tickets nuevos o se eliminan tickets antiguos. Las inserciones pueden desplazar ítems hacia adelante (duplicados entre páginas). Las eliminaciones pueden desplazar ítems hacia atrás (registros desaparecen del recorrido del usuario).
La paginación por offset puede seguir siendo adecuada para tablas pequeñas, datasets estables o exportaciones puntuales. En tablas grandes y activas, los casos límite aparecen rápido.
Paginación por cursor: cómo funciona y por qué se mantiene estable
La paginación por cursor usa un cursor como marcador. En lugar de decir “dame la página 7”, el cliente dice “continúa después de este elemento exacto”. El cursor normalmente codifica los valores de ordenación del último ítem (por ejemplo, created_at e id) para que el servidor pueda reanudar en el lugar correcto.
La petición suele ser simplemente:
limit: cuántos ítems devolvercursor: un token opaco de la respuesta anterior (a menudo llamadoafter)
La respuesta devuelve ítems más un nuevo cursor que apunta al final de ese segmento. La diferencia práctica es que los cursores no piden a la base de datos que cuente y salte filas. Le piden que empiece desde una posición conocida.
Por eso la paginación por cursor se mantiene rápida en listas que avanzan hacia delante. Con un buen índice, la base de datos puede saltar a “ítems después de X” y luego leer las siguientes limit filas. Con offsets, el servidor a menudo tiene que escanear (o al menos saltarse) cada vez más filas a medida que crece el offset.
Para el comportamiento de la UI, la paginación por cursor hace que “Siguiente” sea natural: coges el cursor devuelto y lo envías en la siguiente petición. “Anterior” es opcional y más complicada. Algunas APIs soportan un cursor before, mientras que otras recuperan en reversa y voltean los resultados.
Cuándo elegir cursor, offset o un híbrido
La elección empieza por cómo la gente usa realmente la lista.
La paginación por cursor encaja mejor cuando los usuarios avanzan principalmente hacia delante y la velocidad es lo primordial: registros de actividad, chats, pedidos, tickets, registros de auditoría y la mayoría de scroll infinito en móvil. También se comporta mejor cuando se insertan o eliminan filas mientras alguien navega.
La paginación por offset tiene sentido cuando los usuarios saltan con frecuencia: tablas administrativas clásicas con números de página, ir a una página concreta y navegación rápida adelante-atrás. Es simple de explicar, pero puede volverse más lenta en datasets grandes y menos estable cuando los datos cambian debajo de ti.
Una forma práctica de decidir:
- Elige cursor cuando la acción principal sea “siguiente, siguiente, siguiente”.
- Elige offset cuando “ir a la página N” sea un requisito real.
- Trata los totales como opcionales. Los totales exactos pueden ser caros en tablas enormes.
Los híbridos son comunes. Un enfoque es usar cursor para next/prev por velocidad, más un modo opcional de salto de página para subconjuntos pequeños y filtrados donde los offsets siguen siendo rápidos. Otra es recuperación por cursor con números de página basados en una instantánea cacheada, de modo que la tabla se sienta familiar sin convertir cada petición en trabajo pesado.
Un contrato de API consistente que funcione en web y móvil
Las UIs administrativas se sienten más rápidas cuando cada endpoint de lista se comporta igual. La UI puede cambiar (tabla web con números de página, scroll infinito en móvil), pero el contrato de la API debe permanecer constante para no tener que reaprender reglas de paginación por cada pantalla.
Un contrato práctico tiene tres partes: filas, estado de paginación y totales opcionales. Mantén los nombres idénticos entre endpoints (tickets, users, orders), incluso si el modo de paginación subyacente difiere.
Aquí hay una forma de respuesta que funciona bien para web y móvil:
{
"data": [ { "id": "...", "createdAt": "..." } ],
"page": {
"mode": "cursor",
"limit": 50,
"nextCursor": "...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false
},
"totals": {
"count": 12345,
"filteredCount": 120
}
}
Unos detalles hacen que esto sea fácil de reutilizar:
page.modele dice al cliente qué está haciendo el servidor sin cambiar los nombres de los campos.limitsiempre es el tamaño de página solicitado.nextCursoryprevCursorestán presentes incluso si uno esnull.totalses opcional. Si es caro, devuélvelo solo cuando el cliente lo pida.
Una tabla web aún puede mostrar “Página 3” manteniendo su propio índice de página y llamando a la API repetidamente. Una lista móvil puede ignorar números de página y simplemente solicitar el siguiente bloque.
Si estás construyendo ambos web y móvil con AppMaster, un contrato estable como este da resultados rápido. El mismo comportamiento de lista puede reutilizarse entre pantallas sin lógica de paginación específica por endpoint.
Reglas de ordenación que mantienen la paginación estable
La ordenación es donde la paginación suele romperse. Si el orden puede cambiar entre peticiones, los usuarios verán duplicados, huecos o filas “faltantes”.
Haz de la ordenación un contrato, no una sugerencia. Publica los campos y direcciones de orden permitidos y rechaza cualquier otra cosa. Eso mantiene tu API predecible y evita que los clientes pidan órdenes lentas que parecen inofensivas en desarrollo.
Un orden estable necesita un desempate único. Si ordenas por created_at y dos registros comparten la misma marca de tiempo, añade id (u otra columna única) como la última clave de orden. Sin ello, la base de datos puede devolver valores iguales en cualquier orden.
Reglas prácticas que aguantan:
- Permite ordenar solo por campos indexados y bien definidos (por ejemplo
created_at,updated_at,status,priority). - Siempre incluye un desempate único como clave final (por ejemplo
id ASC). - Define un orden por defecto (por ejemplo
created_at DESC, id DESC) y mantenlo consistente entre clientes. - Documenta cómo se ordenan los nulls (por ejemplo “nulls last” para fechas y números).
La ordenación también impulsa la generación del cursor. Un cursor debe codificar los valores de orden del último ítem en orden, incluyendo el desempate, para que la siguiente página pueda consultar “after” esa tupla. Si el orden cambia, los cursores antiguos quedan inválidos. Trata los parámetros de orden como parte del contrato del cursor.
Filtros y totales sin romper el contrato
Los filtros deben sentirse separados de la paginación. La UI está diciendo “muéstrame un conjunto distinto de filas” y solo entonces pregunta “pagina a través de ese conjunto”. Si mezclas campos de filtro en tu token de paginación o tratas los filtros como opcionales y sin validar, obtendrás comportamientos difíciles de depurar: páginas vacías, duplicados o un cursor que de repente apunta a un dataset diferente.
Una regla simple: los filtros viven en parámetros de consulta sencillos (o en el cuerpo de la petición para POST), y el cursor es opaco y válido solo para esa combinación exacta de filtros y orden. Si el usuario cambia cualquier filtro (status, rango de fechas, asignado), el cliente debe descartar el cursor antiguo y empezar desde el principio.
Sé estricto sobre qué filtros permites. Protege el rendimiento y mantiene el comportamiento predecible:
- Rechaza campos de filtro desconocidos (no los ignores silenciosamente).
- Valida tipos y rangos (fechas, enums, IDs).
- Limita filtros amplios (por ejemplo, máximo 50 IDs en una lista IN).
- Aplica los mismos filtros a datos y totales (sin números desajustados).
Los totales son donde muchas APIs se vuelven lentas. Los conteos exactos pueden ser caros en tablas grandes, especialmente con múltiples filtros. Generalmente tienes tres opciones: exacto, estimado o ninguno. Exacto está bien para datasets pequeños o cuando los usuarios realmente necesitan “mostrando 1-25 de 12,431”. Estimado suele ser suficiente para pantallas administrativas. Ninguno está bien cuando solo necesitas “Cargar más”.
Para evitar ralentizar cada petición, haz los totales opcionales: calcúlalos solo cuando el cliente lo pida (por ejemplo con una bandera como includeTotal=true), cacheálos brevemente por conjunto de filtros o devuélvelos solo en la primera página.
Paso a paso: diseñar e implementar el endpoint
Empieza con valores por defecto. Un endpoint de lista necesita un orden estable, más un desempate para filas que comparten el mismo valor. Por ejemplo: createdAt DESC, id DESC. El desempate (id) es lo que previene duplicados y huecos cuando se añaden registros nuevos.
Define una única forma de petición y mantenla sencilla. Los parámetros típicos son limit, cursor (o offset), sort y filters. Si soportas ambos modos, hazlos mutuamente exclusivos: o el cliente envía cursor, o envía offset, pero no ambos.
Mantén un contrato de respuesta coherente para que web y móvil compartan la misma lógica de lista:
items: la página de registrosnextCursor: el cursor para obtener la siguiente página (onull)hasMore: booleano para que la UI decida si mostrar “Cargar más”total: total de registros que coinciden (nulla menos que se solicite si el conteo es caro)
La implementación es donde los dos enfoques divergen.
Las consultas por offset suelen ser ORDER BY ... LIMIT ... OFFSET ..., lo que puede ralentizar en tablas grandes.
Las consultas por cursor usan condiciones de búsqueda basadas en el último ítem: “dame items donde (createdAt, id) sea menor que el último (createdAt, id)”. Eso mantiene el rendimiento más estable porque la base de datos puede usar índices.
Antes de enviar, añade medidas de seguridad:
- Limita
limit(por ejemplo, máximo 100) y establece un valor por defecto. - Valida
sortcontra una lista permitida. - Valida filtros por tipo y rechaza claves desconocidas.
- Haz el
cursoropaco (codifica los últimos valores de orden) y rechaza cursores malformados. - Decide cómo se solicita
total.
Prueba con datos cambiando debajo de ti. Crea y elimina registros entre peticiones, actualiza campos que afecten la ordenación y verifica que no ves duplicados ni filas faltantes.
Ejemplo: lista de tickets que se mantiene rápida en web y móvil
Un equipo de soporte abre una pantalla administrativa para revisar los tickets más recientes. Necesitan que la lista se sienta instantánea, incluso mientras llegan tickets nuevos y los agentes actualizan otros.
En la web, la UI es una tabla. El orden por defecto es updated_at (más recientes primero) y el equipo suele filtrar a Open o Pending. El mismo endpoint puede soportar ambas acciones con un orden estable y un token cursor.
GET /tickets?status=open&sort=-updated_at&limit=50&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==
La respuesta se mantiene predecible para la UI:
{
"items": [{"id": 1693, "subject": "Login issue", "status": "open", "updated_at": "2026-01-25T11:45:42Z"}],
"page": {"next_cursor": "...", "has_more": true},
"meta": {"total": 128}
}
En móvil, el mismo endpoint alimenta el scroll infinito. La app carga 20 tickets a la vez y luego envía next_cursor para obtener el siguiente lote. Sin lógica de número de página y con menos sorpresas cuando los registros cambian.
La clave es que el cursor codifica la última posición vista (por ejemplo, updated_at más id como desempate). Si un ticket se actualiza mientras el agente se desplaza, puede subir hacia la parte superior en la siguiente actualización, pero no provocará duplicados ni huecos en el feed ya recorrido.
Los totales son útiles, pero caros en datasets grandes. Una regla simple es devolver meta.total solo cuando el usuario aplica un filtro (como status=open) o lo solicita explícitamente.
Errores comunes que causan duplicados, huecos y lentitud
La mayoría de errores de paginación no están en la base de datos. Vienen de pequeñas decisiones de API que parecen bien en pruebas y luego se desmoronan cuando los datos cambian entre peticiones.
La causa más común de duplicados (o filas faltantes) es ordenar por un campo que no es único. Si ordenas por created_at y dos ítems comparten la misma marca de tiempo, el orden puede invertirse entre peticiones. La solución es sencilla: añade siempre un desempate estable, normalmente la clave primaria, y trata la ordenación como un par como (created_at desc, id desc).
Otro problema común es permitir a los clientes pedir cualquier tamaño de página. Una petición grande puede disparar CPU, memoria y tiempos de respuesta, lo que ralentiza todas las pantallas administrativas. Elige un valor por defecto sensato y un máximo estricto, y devuelve un error cuando el cliente pide más.
Los totales también pueden perjudicar. Contar todas las filas que coinciden en cada petición puede convertirse en la parte más lenta del endpoint, especialmente con filtros. Si la UI necesita totales, obténlos solo cuando se pidan (o devuelve una estimación) y evita bloquear el scroll de la lista con un conteo completo.
Errores que con más frecuencia crean huecos, duplicados y lentitud:
- Ordenar sin un desempate único (orden inestable)
- Tamaños de página ilimitados (sobrecarga del servidor)
- Devolver totales en cada petición (consultas lentas)
- Mezclar reglas de offset y cursor en un mismo endpoint (comportamiento confuso para el cliente)
- Reusar el mismo cursor cuando cambian filtros u orden (resultados erróneos)
Reinicia la paginación siempre que cambien filtros u orden. Trata un nuevo filtro como una nueva búsqueda: limpia el cursor/offset y empieza desde la primera página.
Lista rápida de comprobación antes de lanzar
Haz esto una vez con la API y la UI lado a lado. La mayoría de problemas ocurren en el contrato entre la pantalla de lista y el servidor.
- El orden por defecto es estable e incluye un desempate único (por ejemplo
created_at DESC, id DESC). - Los campos y direcciones de orden están en lista blanca.
- Se aplica un tamaño máximo de página, con un valor por defecto sensato.
- Los tokens de cursor son opacos y los cursores inválidos fallan de forma predecible.
- Cualquier cambio de filtro u orden reinicia el estado de paginación.
- El comportamiento de totales es explícito: exacto, estimado u omitido.
- El mismo contrato soporta tabla e scroll infinito sin casos especiales.
Próximos pasos: estandariza tus listas y mantenlas consistentes
Elige una lista administrativa que la gente use a diario y hazla tu estándar de oro. Una tabla concurrida como Tickets, Orders o Users es un buen punto de partida. Una vez que ese endpoint se sienta rápido y predecible, replica el mismo contrato en el resto de pantallas administrativas.
Escribe el contrato, aunque sea breve. Sé explícito sobre lo que la API acepta y lo que devuelve para que el equipo de UI no adivine y no termine inventando reglas diferentes por endpoint.
Un estándar simple para aplicar a cada endpoint de lista:
- Ordenes permitidos: nombres de campo exactos, dirección y un predeterminado claro (más un desempate como
id). - Filtros permitidos: qué campos pueden filtrarse, formatos de valor y qué pasa con filtros inválidos.
- Comportamiento de totales: cuándo devuelves un conteo, cuándo devuelves “desconocido” y cuándo lo omites.
- Forma de respuesta: claves consistentes (
items, información de paginación, sort/filtros aplicados, totales). - Reglas de error: códigos de estado consistentes y mensajes de validación legibles.
Si estás construyendo estas pantallas administrativas con AppMaster (appmaster.io), ayuda estandarizar el contrato de paginación pronto. Puedes reutilizar el mismo comportamiento de listas en tu app web y apps móviles nativas, y pasarás menos tiempo persiguiendo casos límite de paginación más adelante.
FAQ
La paginación por offset usa limit más offset (o page/pageSize) para saltarse filas, por lo que las páginas profundas suelen volverse más lentas porque la base de datos tiene que recorrer más registros. La paginación por cursor usa un token after basado en los valores de ordenación del último ítem, por lo que puede saltar a una posición conocida y mantenerse rápida al avanzar.
Porque la página 1 suele ser barata, pero la página 200 obliga a la base de datos a saltarse un gran número de filas antes de devolver resultados. Si además aplicas ordenación y filtros, el trabajo crece y cada clic se siente como una consulta pesada en lugar de una recuperación rápida.
Usa siempre un orden estable con un desempate único, por ejemplo created_at DESC, id DESC o updated_at DESC, id DESC. Sin el desempate, los registros con la misma marca de tiempo pueden cambiar de orden entre peticiones, lo que causa duplicados o “filas desaparecidas”.
Prefiere la paginación por cursor para listas donde la gente avanza principalmente hacia adelante y la velocidad importa, como logs de actividad, tickets, pedidos y scroll infinito en móvil. Se comporta mejor cuando se insertan o eliminan filas mientras alguien navega, porque el cursor ancla la siguiente página a una posición exacta ya vista.
La paginación por offset tiene sentido cuando la interfaz necesita ir a la "página N" con frecuencia y los usuarios saltan mucho, como en tablas administrativas clásicas. También es práctica para tablas pequeñas o datasets estables donde la desaceleración en páginas profundas no importa.
Mantén una forma de respuesta consistente entre endpoints e incluye los ítems, el estado de paginación y totales opcionales. Un predeterminado práctico es devolver items, un objeto page (con limit, nextCursor/prevCursor o offset) y una bandera ligera como hasNext para que tablas web y listas móviles reutilicen la misma lógica cliente.
Porque un COUNT(*) exacto en tablas grandes y con filtros puede convertirse en la parte más lenta de la petición y hacer que cada cambio de página se sienta pesado. Un valor más seguro es hacer los totales opcionales, devolverlos solo cuando se solicitan, o devolver has_more cuando la UI solo necesita "Cargar más".
Trata los filtros como parte del conjunto de datos y considera el cursor válido solo para esa combinación exacta de filtro y orden. Si el usuario cambia cualquier filtro u orden, reinicia la paginación y comienza desde la primera página; reutilizar un cursor antiguo tras cambios suele causar páginas vacías o resultados confusos.
Haz una lista blanca de campos y direcciones de orden permitidos y rechaza cualquier otro pedido para que los clientes no pidan ordenaciones lentas o inestables. Prefiere ordenar por campos indexados y siempre añade un desempate único como id para mantener el orden determinista entre peticiones.
Aplica un limit máximo, valida filtros y parámetros de orden, y haz que los tokens de cursor sean opacos y se validen estrictamente. Si construyes pantallas administrativas en AppMaster, mantener estas reglas consistentes en todos los endpoints de lista facilita reutilizar comportamiento sin soluciones a medida por pantalla.


