La paginación por cursor mantiene las listas estables cuando los datos cambian. Aprende por qué el paging por offset falla con inserciones y eliminaciones y cómo implementar cursores limpios.

Abres un feed, te desplazas un poco y todo parece normal... hasta que no lo es. Ves el mismo ítem dos veces. Algo que jurabas que estaba ha desaparecido. Una fila que ibas a tocar se desplaza hacia abajo y acabas en la página de detalle equivocada.
Estos son errores visibles para el usuario, incluso si las respuestas de tu API parecen “correctas” por separado. Los síntomas habituales son fáciles de identificar:
Esto empeora en móvil. La gente pausa, cambia de app, pierde conectividad y luego continúa. Durante ese tiempo llegan nuevos ítems, se borran otros y algunos se editan. Si tu app sigue pidiendo “página 3” usando un offset, los límites de página pueden moverse mientras el usuario está a mitad de desplazamiento. El resultado es un feed que se siente inestable y poco fiable.
El objetivo es simple: una vez que un usuario empieza a desplazarse hacia adelante, la lista debería comportarse como una instantánea. Pueden existir nuevos ítems, pero no deberían reorganizar lo que el usuario ya está recorriendo. El usuario debería obtener una secuencia suave y predecible.
Ningún método de paginación es perfecto. Los sistemas reales tienen escrituras concurrentes, ediciones y múltiples opciones de orden. Pero la paginación por cursor suele ser más segura que la por offset porque avanza desde una posición concreta en un orden estable, en lugar de hacerlo desde un recuento de filas que se mueve.
La paginación por offset es el modo “salta N, toma M” para paginar una lista. Le dices a la API cuántas filas saltar (offset) y cuántas devolver (limit). Con limit=20 obtienes 20 ítems por página.
Conceptualmente:
GET /items?limit=20&offset=0 (primera página)GET /items?limit=20&offset=20 (segunda página)GET /items?limit=20&offset=40 (tercera página)La respuesta suele incluir los ítems más suficiente información para pedir la siguiente página.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Es popular porque encaja bien con tablas, listas de administración, resultados de búsqueda y feeds simples. También es fácil de implementar en SQL con LIMIT y OFFSET.
La trampa es la suposición oculta: que el conjunto de datos se mantiene estático mientras el usuario pagina. En apps reales, se insertan nuevas filas, se eliminan filas y cambian las claves de orden. Ahí es donde empiezan los “bugs misteriosos”.
La paginación por offset asume que la lista no cambia entre peticiones. Pero las listas reales se mueven. Cuando la lista se desplaza, un offset como “salta 20” ya no apunta a los mismos ítems.
Imagina un feed ordenado por created_at desc (más recientes primero), tamaño de página 3.
Cargas la página 1 con offset=0, limit=3 y obtienes [A, B, C].
Ahora se crea un nuevo ítem X que aparece arriba. La lista es ahora [X, A, B, C, D, E, F, ...]. Cargas la página 2 con offset=3, limit=3. El servidor salta [X, A, B] y devuelve [C, D, E].
Acabas de ver C de nuevo (un duplicado), y más adelante te perderás un ítem porque todo se desplazó hacia abajo.
Las eliminaciones provocan la falla opuesta. Empieza con [A, B, C, D, E, F, ...]. Cargas la página 1 y ves [A, B, C]. Antes de la página 2, B se elimina y la lista queda [A, C, D, E, F, ...]. La página 2 con offset=3 salta [A, C, D] y devuelve [E, F, G]. D queda como un hueco que nunca recuperas.
En feeds de más recientes primero, las inserciones ocurren en la parte superior, que es exactamente lo que desplaza todos los offsets posteriores.
Una “lista estable” es lo que los usuarios esperan: a medida que se desplazan hacia adelante, los ítems no se mueven, no se repiten ni desaparecen sin motivo. No se trata de congelar el tiempo sino de hacer la paginación predecible.
Se suelen mezclar dos ideas:
created_at con un desempate como id) de modo que dos peticiones con las mismas entradas devuelvan el mismo orden.Refrescar y desplazarse hacia adelante son acciones distintas. Refrescar significa “muéstrame lo nuevo ahora”, así que la parte superior puede cambiar. Desplazarse hacia adelante significa “sigue desde donde estaba”, por lo que no deberías ver repeticiones ni huecos inesperados causados por límites de página que se mueven.
Una regla simple que evita la mayoría de bugs de paginación: desplazarse hacia adelante nunca debe mostrar repeticiones.
La paginación por cursor avanza por una lista usando un marcador en vez de un número de página. En lugar de “dame la página 3”, el cliente dice “continúa desde aquí”.
El contrato es directo:
Esto tolera mejor las inserciones y eliminaciones porque el cursor se ancla a una posición en el orden, no a un conteo de filas.
El requisito no negociable es un orden determinista. Necesitas una regla de orden estable y un desempate consistente; si no, el cursor no es un marcador fiable.
Empieza por elegir un orden que coincida con cómo la gente lee la lista. Los feeds, mensajes y registros de actividad suelen ser más recientes primero. Historiales como facturas y logs de auditoría suelen ser más fáciles de leer de más antiguo a más reciente.
Un cursor debe identificar de forma única una posición en ese orden. Si dos ítems pueden compartir el mismo valor de cursor, tarde o temprano obtendrás duplicados o huecos.
Opciones comunes y en qué fijarse:
created_at solo: simple, pero inseguro si muchas filas comparten la misma marca de tiempo.id solo: seguro si los IDs son monotónicos, pero puede que no refleje el orden de producto deseado.created_at + id: normalmente la mejor mezcla (timestamp para el orden de producto, id como desempate).updated_at como orden principal: arriesgado para scroll infinito porque las ediciones pueden mover ítems entre páginas.Si ofreces múltiples modos de orden, trata cada modo como una lista diferente con sus propias reglas de cursor. Un cursor solo tiene sentido para un orden exacto.
Puedes mantener la superficie de la API pequeña: dos entradas, dos salidas.
Envía un limit (cuántos ítems quieres) y un cursor opcional (dónde continuar). Si falta el cursor, el servidor devuelve la primera página.
Ejemplo de petición:
GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Devuelve los ítems y un next_cursor. Si no hay siguiente página, devuelve next_cursor: null. Los clientes deben tratar el cursor como un token, no como algo para editar.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Lógica del lado del servidor en palabras sencillas: ordena en un orden estable, filtra usando el cursor y luego aplica el límite.
Si ordenas de más recientes a más antiguos por (created_at DESC, id DESC), decodifica el cursor en (created_at, id), luego busca filas donde (created_at, id) sea estrictamente menor que el par del cursor, aplica el mismo orden y toma limit filas.
Puedes codificar el cursor como un blob JSON en base64 (fácil) o como un token firmado/encriptado (más trabajo). Lo opaco es más seguro porque te permite cambiar detalles internos más adelante sin romper a los clientes.
También establece valores predeterminados sensatos: un default móvil razonable (a menudo 20–30), un default para web (a menudo 50) y un máximo duro en el servidor para que un cliente con bug no pida 10.000 filas.
Un feed estable trata principalmente de una promesa: una vez que el usuario empieza a desplazarse hacia adelante, los ítems que no ha visto no deberían reacomodarse porque otra persona creó, eliminó o editó registros.
Con paginación por cursor las inserciones son lo más fácil. Los registros nuevos deberían aparecer al refrescar, no en medio de páginas ya cargadas. Si ordenas por created_at DESC, id DESC, los nuevos ítems naturalmente viven antes de la primera página, así que tu cursor existente continúa hacia ítems más antiguos.
Las eliminaciones no deberían reordenar la lista. Si un ítem se elimina, simplemente no se devolverá cuando te toque traerlo. Si necesitas mantener tamaños de página consistentes, sigue consultando hasta reunir limit ítems visibles.
Las ediciones son donde los equipos accidentalmente reintroducen bugs. La pregunta clave es: ¿puede una edición cambiar la posición de orden?
El comportamiento estilo snapshot suele ser el mejor para listas de desplazamiento: página por una clave inmutable como created_at. Las ediciones pueden cambiar el contenido, pero el ítem no salta a otra posición.
El comportamiento en vivo ordena por algo como edited_at. Eso puede causar saltos (un ítem antiguo se edita y sube al top). Si eliges esto, trata la lista como constantemente cambiante y diseña la UX alrededor del refresco.
No hagas que el cursor dependa de “encontrar esta fila exacta”. Codifica la posición en valores, por ejemplo {created_at, id} del último ítem devuelto. Entonces la siguiente consulta se basa en valores, no en la existencia de la fila:
WHERE (created_at, id) < (:created_at, :id)id) para evitar duplicadosLa paginación hacia adelante es la parte fácil. Las preguntas UX más complicadas son cómo navegar hacia atrás, refrescar y el acceso aleatorio.
Para paginar hacia atrás, dos enfoques suelen funcionar:
next_cursor para ítems más antiguos y prev_cursor para ítems más nuevos) manteniendo un único orden visible en pantalla.El acceso aleatorio es más difícil con cursores porque “página 20” no tiene un significado estable cuando la lista cambia. Si realmente necesitas saltos, salta a un ancla como “alrededor de esta marca de tiempo” o “empezando desde este id”, no a un índice de página.
En móvil, el cache importa. Almacena cursores por estado de lista (consulta + filtros + orden) y trata cada pestaña/vista como su propia lista. Eso evita el comportamiento de “cambiar pestañas y que todo se desordene”.
La mayoría de problemas con la paginación por cursor no vienen de la base de datos. Provienen de pequeñas inconsistencias entre peticiones que solo aparecen con tráfico real.
Los mayores culpables:
created_at) de modo que los empates produzcan duplicados o ítems faltantes.next_cursor que no coincide con el último ítem realmente devuelto.Si desarrollas apps sobre plataformas como Koder.ai, estos casos límite aparecen rápido porque los clientes web y móvil suelen compartir el mismo endpoint. Tener un contrato de cursor explícito y una regla de orden determinista mantiene ambos clientes consistentes.
Antes de dar por terminada la paginación, verifica el comportamiento bajo inserciones, eliminaciones y reintentos.
next_cursor se toma del último registro devueltolimit tiene un máximo seguro y un valor por defecto documentadoPara el refresco, elige una regla clara: o los usuarios hacen pull to refresh para obtener ítems más nuevos en la parte superior, o compruebas periódicamente “¿hay algo más nuevo que mi primer ítem?” y muestras un botón “Nuevos ítems”. La consistencia hace que la lista se sienta estable en lugar de “embrujada”.
Imagina una bandeja de soporte que los agentes usan en web, mientras un manager la consulta en móvil. La lista está ordenada por más recientes primero. La gente espera una cosa: cuando se desplazan hacia adelante, los ítems no deben saltar, repetirse ni desaparecer.
Con paginación por offset, un agente carga la página 1 (ítems 1–20) y luego va a la página 2 (offset=20). Mientras lee, llegan dos mensajes nuevos arriba. Ahora offset=20 apunta a un lugar distinto que hace un segundo. El usuario ve duplicados o se pierde mensajes.
Con paginación por cursor, la app pide “los siguientes 20 ítems después de este cursor”, donde el cursor se basa en el último ítem que el usuario realmente vio (comúnmente (created_at, id)). Pueden llegar mensajes nuevos todo el día, pero la siguiente página sigue empezando justo después del último mensaje que vio el usuario.
Una forma simple de probar antes de lanzar:
Si haces un prototipo rápido, Koder.ai puede ayudarte a esbozar el endpoint y los flujos cliente desde un prompt de chat, luego iterar con Planning Mode además de snapshots y rollback cuando un cambio de paginación te sorprenda en las pruebas.
La paginación por offset indica “salta N filas”, así que cuando se insertan nuevas filas o se eliminan antiguas, el conteo de filas cambia. El mismo offset puede referirse de repente a distintos ítems que tenía un momento antes, lo que genera duplicados y huecos mientras el usuario está desplazándose.
La paginación por cursor usa un marcador que representa “la posición después del último ítem que vi”. La siguiente petición continúa desde esa posición en un orden determinista, por lo que las inserciones en la parte superior y las eliminaciones en el medio no desplazan tu límite de página como ocurre con los offsets.
Usa un orden determinista con un criterio de desempate, normalmente (created_at, id) en la misma dirección. created_at da el orden que ven los usuarios y id hace que cada posición sea única para no repetir ni omitir ítems cuando las marcas de tiempo coinciden.
Ordenar por updated_at puede hacer que los ítems salten entre páginas cuando se editan, lo que rompe la expectativa de “scroll hacia adelante estable”. Si necesitas una vista “recientemente actualizados”, diseña la interfaz para refrescar y aceptar la reordenación en lugar de prometer un desplazamiento infinito estable.
Devuelve un token opaco como next_cursor y haz que el cliente lo envíe de vuelta sin modificar. Un enfoque simple es codificar el (created_at, id) del último ítem en un JSON base64, pero lo importante es tratarlo como un valor opaco para poder cambiar la implementación interna más adelante.
Construye la siguiente consulta a partir de los valores del cursor, no de “encuentra esta fila exacta”. Si el último ítem fue eliminado, el (created_at, id) almacenado sigue definiendo una posición, así que puedes continuar de forma segura usando un filtro de “estrictamente menor” (o “mayor”) en el mismo orden.
Usa una comparación estricta y un desempate único, y siempre toma el cursor del último ítem que realmente devolviste. La mayoría de los bugs de repetición vienen de usar <= en lugar de <, omitir el desempate, o generar el next_cursor a partir de la fila equivocada.
Define una regla clara: el refresco carga ítems más nuevos en la parte superior, mientras que el desplazamiento hacia adelante continúa hacia ítems más antiguos a partir del cursor existente. No mezcles la semántica de refresco dentro del mismo flujo de cursor, o los usuarios verán reordenaciones y pensarán que la lista no es fiable.
Un cursor solo es válido para un orden exacto y un conjunto de filtros. Si el cliente cambia el modo de ordenación, la consulta de búsqueda o los filtros, debe iniciar una nueva sesión de paginación sin cursor y almacenar los cursores por separado según el estado de la lista.
La paginación por cursor es ideal para navegación secuencial pero no para saltos estables a “página 20”, porque el conjunto de datos puede cambiar. Si necesitas saltos, salta a un ancla como “alrededor de esta marca de tiempo” o “empezando después de este id”, y luego pagina con cursores desde ahí.
Almacena los cursores por estado de lista (consulta + filtros + orden) y trata cada pestaña/vista como su propia lista. Eso evita comportamientos como “cambiar de pestaña y que todo se desordene”.