Paginación por offset vs. paginación por cursor/keyset — ¿qué se rompe con offset a gran escala?
LIMIT/OFFSET parece la forma obvia de paginar una lista, hasta que la página 500 obliga a la base de datos a recorrer medio millón de filas solo para descartarlas.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Paginación por offset vs. paginación por cursor/keyset — ¿qué se rompe con offset a gran escala?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#La consulta que parece perfectamente razonable
Casi todos los tutoriales de paginación empiezan así:
SELECT id, customer_id, total_cents, created_atFROM ordersORDER BY created_at DESCLIMIT 20 OFFSET 500000;
Funciona perfecto en una demo con doscientas filas. Se convierte en un incidente de producción en cuanto orders tiene cincuenta millones de filas y alguien intenta renderizar el "dashboard de la página 25.000".
#Lo que OFFSET le cuesta realmente a la base de datos
OFFSET no es un marcador de posición. La base de datos no recuerda dónde se quedó la petición anterior. Cada llamada vuelve a ejecutar el ORDER BY completo, recorre el resultado desde el principio y descarta las primeras OFFSET filas antes de empezar a entregarte el LIMIT que pediste. Incluso con un índice perfecto sobre created_at, ese recorrido sigue siendo proporcional al offset: el motor tiene que atravesar esa cantidad de entradas del índice (y muchas veces ir al heap a aplicar filtros adicionales) antes de llegar a tu página.
Eso te deja un costo de aproximadamente O(offset + limit) por petición. Si paginás una tabla de n filas completa, una página a la vez, el trabajo total hecho a lo largo de todas las peticiones se acerca a O(n²), porque cada página siguiente vuelve a recorrer todo lo anterior, un poco más profundo que la anterior.
Y hay un problema de corrección escondido acá, no solo de rendimiento. OFFSET cuenta las filas de cero en cada consulta. Si se inserta una orden nueva mientras un usuario está mirando la página 3, todo lo que viene después se corre una posición: la siguiente página puede mostrar en silencio una fila duplicada que el usuario ya vio, o saltearse una por completo. La paginación por offset solo es "correcta" contra una tabla perfectamente estática.
#La paginación por keyset hace otra pregunta
En lugar de "saltate N filas y dame la página siguiente", la paginación por keyset (o cursor) pregunta "dame las filas que vienen después de la última que ya vi":
-- primera página: todavía no hay cursorSELECT id, customer_id, total_cents, created_atFROM ordersORDER BY created_at DESC, id DESCLIMIT 20;-- cada página siguiente: el cliente devuelve la última fila que recibióSELECT id, customer_id, total_cents, created_atFROM ordersWHERE (created_at, id) < ($1, $2) -- $1, $2 = created_at, id de la última fila de la página anteriorORDER BY created_at DESC, id DESCLIMIT 20;
Dos detalles importan acá. Primero, id está incluido puramente como desempate: created_at solo no es un orden total y único —dos órdenes pueden compartir timestamp— y sin un desempate, las filas se pueden seguir salteando o duplicando exactamente igual que con OFFSET. Segundo, la comparación de tupla (created_at, id) < ($1, $2) —soportada directamente por Postgres y MySQL 8+— es lo que hace esto rápido: con un índice compuesto sobre (created_at DESC, id DESC), el planificador convierte ese predicado en un recorrido de rango sobre el índice que salta directo al límite en aproximadamente O(log n), y después lee exactamente limit filas hacia adelante. El costo de la página 50.000 es idéntico al de la página 2.
#Mantener el cursor opaco
Las APIs públicas en general no le entregan al cliente la tupla cruda (created_at, id) para que arme su propio WHERE. Devuelven un token opaco:
{"data": [ /* 20 órdenes */ ],"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNVQxMjozMDowMFoiLCJpZCI6ImNhcnRfNDgxIn0="}
El cliente simplemente devuelve next_cursor en la siguiente petición; el servidor lo decodifica (y lo firma, idealmente) de manera interna. Esa indirección importa por algo más que prolijidad: te permite cambiar la clave de orden subyacente o la estrategia de indexado más adelante sin romper a todos los clientes que memorizaron la forma de tus filas.
#Comparación, lado a lado
| Offset / Limit | Keyset / Cursor | |
|---|---|---|
| Costo por página a gran escala | O(offset + limit) — crece con el número de página | O(log n + limit) — constante, sin importar la página |
| Saltar a una página arbitraria ("página 42 de 900") | Sí, trivial | No — solo hacia adelante/atrás desde un cursor conocido |
| Correcto con inserciones/borrados concurrentes | No — el contenido de la página se corre con escrituras concurrentes | Sí — la posición está anclada a una fila, no a un conteo |
| UI barata de "página 3 de 47" | Combina naturalmente con una consulta de conteo | El conteo total sigue necesitando su propia consulta (posiblemente cara) |
| Múltiples columnas de orden | ORDER BY lo resuelve gratis | Necesita un cursor compuesto y un índice compuesto que coincida |
#Cuál elegir
El offset se gana su lugar en tablas administrativas chicas y con poca escritura, donde alguien realmente quiere escribir un número de página. En todo lo demás —APIs públicas, feeds de scroll infinito, cualquier cosa sobre una tabla grande o que crece rápido— la paginación por keyset es la opción por defecto de un ingeniero senior, y por eso Stripe, GitHub y Slack paginan sus APIs por cursor y no por offset.