Página basada en cursor versus paginación por desplazamiento — ¿Por qué la página=2 elimina silenciosamente registros?
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.
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 comonext_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 desplazamiento | Paginación por cursor | |
|---|---|---|
| SQL | LIMIT x OFFSET y | WHERE (created_at, id) < (cursor) LIMIT x |
| Consistente ante inserciones concurrentes | No — omite silenciosamente registros | Sí — la posición es estable |
| Saltar a una página arbitraria | Sí (OFFSET = page * size) | No — solo recorrido hacia adelante |
| Total de páginas | Fácil (COUNT(*) / size) | No posible sin escaneo completo |
| Rendimiento en grandes desplazamientos | Se degrada — OFFSET 100000 escanea 100k filas para descartarlas | Estable — siempre usa un escaneo por rango de índice |
| Complejidad del cliente | Baja — solo un número de página | Mayor — 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
Instalar extensiones
Agregue herramientas IO a su navegador favorito para obtener acceso instantáneo y búsquedas más rápidas
恵 ¡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!
Herramientas clave
Ver todo Los recién llegados
Ver todoActualizar: Nuestro última herramienta fue agregado el 16 de junio de 2026
