¿Odias los anuncios? Ir Sin publicidad Hoy

Página basada en cursor versus paginación por desplazamiento — ¿Por qué la página=2 elimina silenciosamente registros?

Actualizado en

La paginación por desplazamiento tiene un error reproducible: al insertar nuevas filas entre las peticiones de página, se omiten registros silenciosamente. Aquí está el SQL que demuestra este problema, y cuándo usar paginación por cursor en su lugar.

La paginación por desplazamiento tiene un bug reproducible: se omiten registros cuando se insertan nuevas filas entre las solicitudes de página. Aquí está la consulta SQL que lo demuestra, y cuándo usar paginación por cursor en su lugar.
ANUNCIO · ¿ELIMINAR?

Estás construyendo un feed. La página 1 se carga, el usuario desciende, y obtienes la página 2. Pero algunos elementos que existían cuando se cargó la página 1 no aparecen en la página 2. No hay error, ni advertencia — simplemente desaparecen.

Este es el bug de paginación por desplazamiento, y no es una condición de carrera. Es determinista. Cada sistema que use LIMIT x OFFSET y una tabla en tiempo real y con muchas escrituras eventualmente lo enfrentará.

La consulta SQL que lo causa

La paginación por desplazamiento se traduce directamente a:

-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;

-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;

La base de datos no tiene memoria de qué estaba en la página 1 cuando se pide la página 2. Cuenta desde la posición 0 cada vez, contra las filas que existan en ese momento.

La escena que lo rompe

Comienza con 20 publicaciones, IDs de 1 a 20, más recientes primero. El usuario carga la página 1 — obtiene los IDs de 20 a 11. Entre esta solicitud y la siguiente, se insertan tres nuevas publicaciones: IDs 21, 22 y 23.

Ahora el usuario desciende y obtiene la página 2:

SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1

Las publicaciones 10, 9 y 8 — que el usuario aún no ha visto — simplemente han sido empujadas por fuera del marco de desplazamiento. Tres nuevas inserciones han causado exactamente tres registros omitidos. El cálculo es siempre exacto.

El usuario no ve nada mal. Los registros de tus logs muestran nada mal. Los registros existen en la base de datos. Simplemente son invisibles para esa sesión.

La paginación por cursor soluciona esto

En lugar de contar posiciones, se ancla la siguiente página al último registro que realmente se devolvió. La consulta se convierte en "dame los siguientes 10 registros después de este registro específico" en lugar de "dame los registros 11 a 20".

-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11

-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
   OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;

Las nuevas inserciones no afectan esto en absoluto. Se colocan al principio del orden de clasificación, antes de la posición del cursor. Todo desde el cursor en adelante es estable, independientemente de las escrituras concurrentes.

Qué es exactamente un cursor

Un cursor es la posición codificada del último registro devuelto. En la práctica, normalmente es uno de:

  • Un par de timestamp + ID codificado — el enfoque más común. Serializa {"created_at": "...", "id": 11} como una cadena base64 y devuelve como next_cursor.
  • Un UUID o un ID de fila opaco — algunos sistemas usan directamente la clave primaria cuando está ordenada por tiempo (ULIDs, UUID v7). Un generador de UUID es útil cuando estás decidiendo entre formatos de UUID para tu esquema — el v4 es aleatorio y no se ordena cronológicamente, el v7 es basado en tiempo y sí lo hace.
  • Un token firmado — el enfoque de Stripe, que impide que los clientes fabriquen valores de cursor que salten a posiciones arbitrarias.

Cuándo la paginación por desplazamiento sigue siendo adecuada

No todos los casos necesitan cursores. La paginación por desplazamiento es adecuada cuando:

  • El conjunto de datos es estático o solo se añade al final. Páginas de archivo, documentación, colas de exportación. Si nada se inserta en la parte superior de tu orden de clasificación, el desplazamiento permanece estable.
  • Dashboards de administración con números de página explícitos. Los usuarios esperan saltar a "página 5" y ver esa sección específica. Los cursors no pueden hacer esto — no existe el concepto de "página N" cuando tu anclaje es un registro, no un conteo.
  • Conjuntos de datos pequeños bajo tu control. Paginando 200 registros en una herramienta interna donde los nuevos datos llegan mediante trabajos diarios? La complejidad de los cursors no vale la pena.
  • Las inserciones solo ocurren al final del orden de clasificación. Si tu paginación es ascendente por ID y los nuevos registros tienen IDs incrementales, las páginas anteriores no están afectadas por nuevas inserciones.

La pregunta decisiva: ¿pueden insertarse nuevas filas antes en la posición actual del cursor entre solicitudes? Si la respuesta es sí, la paginación por desplazamiento silenciosamente omite registros. Si no, está bien.

Comparación

Paginación por desplazamientoPaginación por cursor
SQLLIMIT x OFFSET yWHERE (created_at, id) < (cursor) LIMIT x
Consistente ante inserciones concurrentesNo — omite silenciosamente registrosSí — la posición es estable
Saltar a una página arbitrariaSí (OFFSET = page * size)No — solo recorrido hacia adelante
Total de páginasFácil (COUNT(*) / size)No posible sin escaneo completo
Rendimiento en grandes desplazamientosSe degrada — OFFSET 100000 escanea 100k filas para descartarlasEstable — siempre usa un escaneo por rango de índice
Complejidad del clienteBaja — solo un número de páginaMayor — debe almacenar y transmitir el token del cursor

Cómo GitHub y Stripe lo manejan

Ambos APIs de GitHub usan

GitHub

en la mayoría de los endpoints, pero el API de GraphQL usa paginación por cursor con page y per_page Este cursor ( after y before:

query {
  repository(owner: "vercel", name: "next.js") {
    issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
      pageInfo {
        endCursor
        hasNextPage
      }
      nodes {
        title
        number
      }
    }
  }
}

) está codificado en base64 — decodifícalo y obtienes una referencia de fila interna, no un número de página. En un repositorio como next.js, pueden presentarse docenas de issues entre tu página 1 y tu página 2. Con paginación por desplazamiento, omitirías algunos de ellos silenciosamente.Y3Vyc29yOnYyOpHOAABGPQ==Stripe tiene sus APIs de lista que usan

Stripe

— pasas el ID del último objeto que recibiste: starting_after y ending_before Los objetos de Stripe tienen IDs ordenados por tiempo (

# First page
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10

# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10   -d starting_after=ch_1ABC123def456

), por lo que el ID en sí funciona como cursor. Su API nunca ha ofrecido paginación por desplazamiento porque los datos de facturación son exactamente donde los saltos silenciosos se convierten en un problema grave — una factura perdida no es solo un problema de UX.ch_, cus_, pi_Implementación de cursors: un patrón mínimo

Aquí tienes una implementación mínima en Node.js/PostgreSQL usando un cursor compuesto de timestamp + ID:

Una cosa importante de tener en cuenta: si estás usando UUIDs como claves primarias en lugar de enteros autoincrementables, el orden compuesto solo funciona si tus UUIDs están ordenados por tiempo. El UUID v4 es aleatorio — ordenar por

function encodeCursor(row) {
  return Buffer.from(JSON.stringify({
    created_at: row.created_at,
    id: row.id
  })).toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}

async function fetchPage(db, cursor = null, limit = 10) {
  let rows;

  if (!cursor) {
    rows = await db.query(
      'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
      [limit]
    );
  } else {
    const { created_at, id } = decodeCursor(cursor);
    // PostgreSQL supports tuple comparison natively.
    // MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
    rows = await db.query(
      `SELECT * FROM posts
       WHERE (created_at, id) < ($1, $2)
       ORDER BY created_at DESC, id DESC
       LIMIT $3`,
      [created_at, id, limit]
    );
  }

  const nextCursor = rows.length === limit
    ? encodeCursor(rows[rows.length - 1])
    : null;

  return { rows, nextCursor };
}

funciona, pero pierdes la garantía de empate que proporciona una ID secuencial. Los UUID v7 o ULIDs están ordenados por tiempo y evitan completamente este problema. (created_at, uuid_v4) La paginación por desplazamiento es simple de implementar y funciona bien hasta que tus datos son en tiempo real y de escritura intensa. El momento en que los usuarios pueden provocar inserciones entre las solicitudes de paginación — publicaciones, cargos, issues, cualquier cosa — la paginación por desplazamiento silenciosamente omite registros. El número de registros omitidos es exactamente igual al número de nuevas inserciones en la parte superior de tu orden de clasificación. No hay error, ni advertencia.

La versión breve

La paginación por cursor cambia "saltar a página N" por estabilidad de posición. Para feeds de usuarios y cualquier API que otros desarrollarán, esta es la mejor opción. La paginación por desplazamiento es adecuada para herramientas de administración y exportaciones estáticas — no añadas complejidad de cursor donde el error no pueda ocurrir.

Paginación por cursor vs paginación por desplazamiento — por qué la página 2 omite silenciosamente registros 2

¿Quieres eliminar publicidad? Adiós publicidad hoy

Instalar extensiones

Agregue herramientas IO a su navegador favorito para obtener acceso instantáneo y búsquedas más rápidas

añadir Extensión de Chrome añadir Extensión de borde añadir Extensión de Firefox añadir Extensión de Opera

¡El marcador ha llegado!

Marcador es una forma divertida de llevar un registro de tus juegos, todos los datos se almacenan en tu navegador. ¡Próximamente habrá más funciones!

ANUNCIO · ¿ELIMINAR?
ANUNCIO · ¿ELIMINAR?
ANUNCIO · ¿ELIMINAR?

Noticias Aspectos técnicos clave

Involucrarse

Ayúdanos a seguir brindando valiosas herramientas gratuitas

Invítame a un café
ANUNCIO · ¿ELIMINAR?