23 sept 2025·5 min de lectura

Ordenación estable en paginación: evita que los elementos se reordenen

Aprende cómo la ordenación estable con paginación mantiene los resultados coherentes añadiendo desempates, eligiendo claves de ordenación seguras y usando comprobaciones rápidas para evitar reordenamientos.

Ordenación estable en paginación: evita que los elementos se reordenen

Cómo se ve “que las páginas de la lista se reordenen” en apps reales

Abres una lista, vas a la página 2 y ves un elemento que jurarías que estaba en la página 1 hace un segundo. Actualizas y el orden cambia otra vez. A veces un elemento aparece en ambas páginas. A veces desaparece hasta que ajustas un filtro.

Eso es “que las páginas de la lista se reordenen”. La app está paginando, pero el orden no está completamente fijado, así que la base de datos (o la API) devuelve los registros en un orden ligeramente distinto de una solicitud a otra.

Parece algo menor, pero rompe la confianza rápido. Los usuarios asumen que faltan datos, que las notificaciones no son fiables o que alguien cambió los registros. En pantallas de administración y reportes puede provocar errores reales: revisar la misma fila dos veces y saltarse otra.

Lo notarás sobre todo en tablas de administración, feeds de actividad, resultados de búsqueda, logs de auditoría y catálogos de producto.

“Estable” no significa que la lista nunca cambie. Significa ordenación estable bajo paginación: si las entradas son las mismas (mismos filtros, misma opción de orden, mismo snapshot de datos), obtienes el mismo orden siempre.

Una lista estable tiene tres rasgos:

  • Los empates se resuelven siempre de la misma forma (no hay orden “aleatorio” para registros que comparten el mismo valor de ordenación).
  • Los límites de página son predecibles (un elemento está en la página 1 o en la página 2, no en ambas).
  • Actualizar no reordena los elementos a menos que los datos subyacentes hayan cambiado.

Un desencadenante común es ordenar por un campo que suele tener duplicados, como created_at, status o score. Si diez elementos comparten la misma marca de tiempo o puntuación, la base de datos puede devolver esos diez en cualquier orden a menos que le digas cómo romper el empate.

Por qué la paginación falla cuando la ordenación no es determinista

La paginación depende de una suposición simple: ejecutar la misma consulta dos veces devuelve el mismo orden.

Cuando el orden no está garantizado, tu “página 2” deja de ser realmente la página 2. Los usuarios ven repetidos, elementos faltantes o filas que parecen saltar de sitio.

La causa habitual son los empates. Tu consulta ordena por algo como created_at, score o name, y varias filas comparten el mismo valor. En ese caso, la base de datos puede devolver las filas empatadas en cualquier orden a menos que añadas una regla clara para romper el empate. Ese “cualquier orden” puede cambiar entre solicitudes.

La paginación por offset hace esto aún más visible porque los offsets cuentan posiciones, no filas específicas. Si las posiciones cambian, los offsets apuntan a un trozo distinto de la lista.

Aunque tus datos no cambien, los empates pueden invertirse por razones fuera de tu control: se usa otro índice, el plan de consulta cambia tras actualizar las estadísticas, o la ejecución en paralelo devuelve lotes en distinto orden. Eso es comportamiento normal cuando no pides un orden determinista.

Elige una regla de ordenación estable (clave principal más desempate)

Para evitar que se reordenen, necesitas una regla de ordenación que no deje empates sin resolver.

Elige lo que los usuarios esperan

Empieza por el campo que coincide con cómo piensan las personas.

  • Feed de actividad: más recientes primero.
  • Clasificación: puntuación más alta primero.
  • Directorio: nombres de la A a la Z.

Luego asume que habrá empates. Muchos eventos comparten la misma marca de tiempo, muchos usuarios comparten apellidos y muchos ítems comparten la misma puntuación redondeada.

Añade un desempate que nunca cambie

Agrega una segunda clave de ordenación que sea única y estable. La mayoría de apps ya la tienen: una clave primaria como id. El desempate no debe cambiar con el tiempo y debe existir en cada fila.

Sé explícito sobre la dirección de cada clave. Si tu orden principal es DESC (más recientes primero), usar DESC para el desempate suele mantener el comportamiento intuitivo.

Una regla simple que puedes reutilizar en varios endpoints:

  • Ordena primero por el campo que ve el usuario.
  • Ordena después por id para romper empates.
  • Especifica la dirección para ambos campos.
  • Usa la misma regla exacta en todos los sitios que sirvan esa lista.

Escríbelo en una frase: por ejemplo, “Mostrar ítems más nuevos primero; si las marcas de tiempo son iguales, mostrar ids más altos primero.” Esto evita desviaciones donde un endpoint usa un orden y otro usa uno ligeramente distinto.

Paso a paso: añade desempates a tus consultas

La solución más rápida casi siempre es la misma: conserva tu orden principal y añade un desempate único.

Empieza por la consulta exacta que ejecuta tu API. Encuentra el ORDER BY actual y pregúntate: ¿pueden dos filas tener los mismos valores para estos campos de ordenación? Si la respuesta es sí, tienes un empate y la base de datos puede devolver las filas empatadas en distinto orden.

Un patrón común para “más recientes primero” se parece a esto:

SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

Si ordenas por un campo no único como created_at, price o score, incluye siempre id (u otra columna única) como orden final.

Un detalle práctico más: revisa tu índice. Para el ejemplo anterior, un índice compuesto como (status, created_at, id) (la dirección depende de tu base de datos) suele evitar ordenaciones lentas y mantiene el rendimiento predecible.

Offset vs paginación por cursor: qué cambia para la ordenación estable

Repara feeds y registros rotos
Los feeds de actividad y los registros de auditoría no deben reordenarse al actualizar.

La paginación por offset es el enfoque clásico “page=3”: ordena, omite las primeras N filas y toma el siguiente bloque. Es fácil de implementar, pero asume que el orden se mantiene.

La paginación por cursor es el enfoque “after=item_123”: en lugar de saltarte filas, obtienes los elementos después del último que ya tienes. Suele rendir mejor y evita casos límite feos, pero solo si tu orden es estable y único.

La paginación por cursor hace el requisito explícito: el cursor debe describir una posición precisa en un orden determinista. Eso normalmente implica usar un orden compuesto y un cursor compuesto.

Por ejemplo: ordenar por created_at DESC, id DESC, y hacer que tu cursor lleve ambos valores. Si solo almacenas created_at en el cursor, el límite es ambiguo cuando varias filas comparten la misma marca de tiempo.

Reglas prácticas:

  • Incluye siempre un desempate único en ORDER BY.
  • Haz que el cursor coincida con todo el ORDER BY (mismos campos, mismas direcciones).
  • Si los usuarios cambian filtros o la opción de orden, trátalo como una nueva consulta y empieza con un cursor nuevo.

Datos nuevos y actualizaciones: mantener resultados consistentes en el tiempo

Incluso con un ORDER BY perfecto, las páginas pueden seguir pareciendo inestables si los datos subyacentes cambian entre solicitudes. El orden es determinista, pero el conjunto de datos se mueve.

Los elementos nuevos son el problema clásico con la paginación por offset. Si obtienes la página 1 (ítems 1-20) y luego llega un nuevo elemento al principio, tu siguiente solicitud para la página 2 (offset 20) parte de lo que antes era el ítem 21, pero todo se ha desplazado. Los usuarios ven duplicados o se pierden elementos.

Las ediciones pueden ser peor. Si tu campo de orden cambia (por ejemplo, updated_at), una fila existente puede saltar entre páginas.

La solución empieza con una decisión de producto: ¿esta lista debe ser “en vivo” o debe ser consistente durante una sesión?

Si quieres consistencia, ancla los resultados a un punto de snapshot. Enfoques comunes:

  • Anclar a una marca de tiempo fija (solo mostrar ítems con created_at <= la hora de la primera carga).
  • Anclar al límite de cursor (solo mostrar ítems por debajo del cursor superior de la primera página).
  • Evitar ordenar feeds por updated_at a menos que eso sea exactamente lo que los usuarios esperan.
  • Mostrar un banner de “Nuevos ítems” y permitir que el usuario actualice intencionalmente.

Ejemplo: un feed de actividad ordenado por updated_at DESC. Un usuario abre la página 1 y luego alguien edita un registro antiguo, actualizando updated_at y llevándolo arriba. Cuando el usuario carga la página 2, ve una entrada que ya leyó y falta otra. Anclar a la hora de la primera carga, o cambiar el feed a created_at, elimina ese salto.

Errores comunes que hacen que los ítems salten entre páginas

La mayoría de bugs de “ítems que se mueven” son errores de ordenación.

Culpables habituales:

  • Ordenar solo por un campo no único como created_at, status o name sin un desempate.
  • Usar orden aleatorio (o una puntuación que cambia) y tratarlo como lista estable.
  • Paginación en SQL y luego ordenar en el código de la aplicación después.
  • Endpoints distintos usando órdenes por defecto diferentes para la misma lista.
  • Olvidar definir dónde van los valores NULL.

Versiones sutiles del mismo problema aparecen cuando las etiquetas de la UI no coinciden con el orden real (por ejemplo, mostrar “Última actualización” pero ordenar por “Creado”). Los usuarios lo viven como reordenamientos porque la lista no se comporta como la pantalla promete.

Comprobaciones rápidas antes de sacar a producción

Audita tus reglas de ORDER BY
Una política clara de ordenamiento en todas las pantallas evita repeticiones y huecos.

Estos bugs suelen colarse porque la consulta parece correcta en una base de datos pequeña de desarrollo y luego falla con volumen real y cambios reales.

Una lista corta de control:

  • Termina tu orden con un campo único (a menudo id) para que dos filas nunca empaten.
  • Especifica ASC/DESC para cada campo ordenado.
  • Aplica el orden en la consulta de la base de datos antes de LIMIT/OFFSET (o antes del filtro del cursor), no después de recuperar los datos.
  • Si usas paginación por cursor, incluye todos los campos de orden en el cursor.
  • Confirma que tienes un índice que coincida con tus filtros habituales más la ordenación.

Una prueba rápida de realidad: carga la página 1 y la página 2, inserta una fila nueva que empate en el campo principal de orden (misma marca de tiempo por segundo, misma puntuación, etc.). Recarga. Si los ítems cambian de lugar o aparecen en ambas páginas, todavía tienes un empate sin resolver.

Ejemplo: arreglar un feed de actividad que se reordena

Un equipo lanza un feed de actividad construido con una herramienta de codificación por IA. En pruebas parece bien, pero los usuarios se quejan: “Vi un ítem en la página 2, actualicé y se pasó a la página 1.” El equipo culpa a la caché, pero el problema real es la ordenación.

El feed solo ordena por created_at DESC. En producción, muchas filas comparten la misma marca de tiempo (inserciones por lotes, jobs en background o baja precisión de timestamps). Cuando varios ítems tienen el mismo created_at, la base de datos puede devolverlos en cualquier orden, así que los límites de página tiemblan.

Antes:

SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

Después (determinista):

SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;

Si usas paginación por cursor, actualiza el cursor para que lleve ambos campos. En lugar de “último created_at visto”, usa un cursor compuesto como (created_at, id) y trae ítems “menores que” ese par manteniendo el mismo orden.

Cómo probar y monitorizar la estabilidad en producción

¿No estás seguro de qué falla?
Envía tu repositorio para una auditoría de código gratuita y un plan claro de próximos pasos.

La definición más simple de éxito: la misma solicitud, hecha dos veces seguidas, devuelve los mismos IDs de ítems en el mismo orden (a menos que permitas intencionalmente que aparezcan ítems nuevos).

Buenas pruebas:

  • Recuperar la página 1 dos veces con los mismos parámetros y comparar los IDs devueltos en orden.
  • Recuperar la página 2 y luego la página 1 otra vez, y verificar que la página 1 no cambió.
  • Asegurar que cada ID de ítem aparece como máximo una vez entre la página 1 y la página 2.
  • Verificar el orden comprobando que (campo_de_orden, desempate) es estrictamente monótono.

Cuando los usuarios reporten un reordenamiento, registra lo necesario para reproducirlo: filtros, limit/offset o valores de cursor, y los campos completos de ordenación (incluido el desempate).

Tras cambiar ordenaciones o índices, monitoriza la latencia de consultas (especialmente p95) y los logs de consultas lentas. Si el rendimiento cae, suele ser un problema de índices, no una razón para renunciar al orden determinista.

Próximos pasos: hacer la ordenación consistente en toda la app

Una vez que arregles una pantalla, el siguiente bug suele aparecer en otra parte porque la misma lógica de listas existe en múltiples sitios.

Haz un inventario rápido de todos los lugares donde devuelves una lista: pantallas de usuario, tablas de administración, búsquedas, exportaciones y jobs en background que paginan datos. Luego aplica una política compartida: cada lista tiene un ORDER BY determinista que termina con un desempate único, y todos los endpoints lo usan.

Si heredaste una base de código generada por IA donde las consultas de lista se copiaron y retocaron por pantalla, una auditoría enfocada de ordenación puede dar mucho resultado rápido. A veces los equipos llevan este tipo de problema a FixMyMess (fixmymess.ai) cuando los feeds y las tablas de administración siguen reordenándose en producción, porque a menudo se trata de desempates faltantes y reglas de ordenación inconsistentes entre endpoints.