Patrones de Paginación para APIs Modernas
Compara la paginación offset, cursor, keyset, page tokens y Relay GraphQL con ejemplos en TypeScript y SQL para APIs modernas.
Para los nuevos endpoints de listado, utiliza por defecto la paginación por cursor respaldada por una consulta keyset — ofrece un tiempo de consulta casi constante independientemente del tamaño del conjunto de datos y permanece estable cuando se insertan o eliminan filas entre solicitudes de página. Usa la paginación por offset solo cuando los usuarios necesiten saltar a un número de página arbitrario, el conjunto de datos sea pequeño y se actualice con poca frecuencia, o cuando mostrar el recuento total de páginas sea un requisito estricto de la interfaz de usuario. La decisión gira en torno a dos modos de fallo de los que la paginación por offset no puede escapar: la ralentización lineal en offsets profundos y el bug de ventana deslizante que duplica u omite elementos cuando los datos cambian durante el recorrido. Este artículo mapea todos los patrones de la familia — offset, cursor, keyset, page tokens y conexiones GraphQL estilo Relay — con TypeScript ejecutable y el SQL que respalda cada uno.
Puntos Clave
- Cursor y keyset no son sinónimos: un cursor es el token opaco que tu API devuelve al cliente; keyset es la técnica SQL — un predicado compuesto
WHERE (sort_col, id) < (val, id)— que hace que la paginación por cursor sea rápida al permitir que la base de datos busque directamente en un índice en lugar de escanear y descartar filas. - Una consulta con
OFFSET 500000 LIMIT 20no omite 500.000 filas; PostgreSQL las lee y descarta antes de devolver 20, por lo que el tiempo de consulta crece con el valor del offset independientemente de los índices en la columna de ordenación. - Cuando se insertan filas entre solicitudes de página, la paginación por offset desplaza la ventana: el cliente recibe un elemento duplicado y omite otro silenciosamente, sin ningún error en la respuesta.
- Devolver
total_counten cada respuesta obliga a ejecutar unCOUNT(*)adicional; es preferible usarhas_next_page, que se obtiene de forma económica recuperandolimit + 1filas y verificando si existe la fila extra. - Un cursor codifica una posición en un conjunto de resultados ordenado y filtrado específico, por lo que debe descartarse y reiniciarse a la primera página siempre que cambien los parámetros de ordenación o filtrado.
Por Qué Importa la Estrategia de Paginación: Dos Modos de Fallo
La estrategia de paginación importa porque el comportamiento predeterminado más común — LIMIT y OFFSET — tiene dos modos de fallo estructurales que solo se manifiestan a escala o bajo escrituras concurrentes. El primero es de rendimiento: los offsets profundos obligan a la base de datos a leer y descartar filas. El segundo es de consistencia: los offsets son posicionales, por lo que cualquier inserción o eliminación entre solicitudes de página desplaza la ventana y corrompe el conjunto de resultados. Ninguno de estos bugs aparece en una tabla pequeña y estática, razón por la cual la paginación por offset funciona bien en desarrollo y falla en producción.
A continuación se presenta la comparación que el resto del artículo justifica:
| Patrón | Acceso aleatorio a páginas | Estable bajo escrituras | Rendimiento en BD a profundidad | Recuento total | Ideal para |
|---|---|---|---|---|---|
| Offset / limit | Sí | No | Degradación lineal | Económico (consulta extra) | Datos estáticos pequeños, tablas de administración, interfaces de búsqueda con números de página |
| Cursor (superficie de API) | No | Sí | Constante con keyset | Omitir; usar has_more | Scroll infinito, feeds, APIs de listado públicas |
| Keyset (técnica de BD) | No | Sí | Constante (búsqueda por índice) | Difícil | La implementación subyacente a una API de cursor |
| Page token | No | Sí | Definido por la implementación | Opcional | APIs que desean ocultar y evolucionar su estrategia |
| Conexión Relay | No | Sí | Constante con keyset | Opcional | Clientes GraphQL que usan la especificación de conexiones |
Los dos mecanismos del lado derecho — rendimiento y estabilidad — son los que diferencian los patrones. Todo lo que sigue explica el porqué.
Paginación por Offset: Cómo Falla la Ventana Deslizante
Discover how at OpenReplay.com.
La paginación por offset usa page y limit (o offset y limit) para calcular una posición numérica: offset = (page - 1) * limit. Es el único patrón que soporta saltar a un número de página arbitrario, lo que lo convierte en la opción correcta para tablas de administración y resultados de búsqueda donde los usuarios hacen clic directamente en “página 7”. Sus debilidades son consecuencia directa de ese direccionamiento posicional.
La debilidad de rendimiento es un comportamiento documentado, no una peculiaridad de implementación. Según la documentación de PostgreSQL sobre LIMIT y OFFSET, “las filas omitidas por una cláusula OFFSET aún deben calcularse dentro del servidor”. Una consulta con OFFSET 500000 LIMIT 20 no omite 500.000 filas — la base de datos las lee y descarta antes de devolver 20, lo que significa que el tiempo de consulta crece con el valor del offset independientemente de los índices en la columna de ordenación. Esta semántica no ha cambiado en las versiones recientes de PostgreSQL (16 a 18).
La debilidad de consistencia es el bug de ventana deslizante. Cuando se insertan filas al inicio de un feed entre solicitudes de página, la paginación por offset desplaza la ventana: el elemento en la posición offset de la página N+1 es el que estaba en la posición offset - 1 de la página N, por lo que el cliente recibe un duplicado — y el elemento que quedó fuera del límite se omite silenciosamente, sin ningún error en la respuesta. Una eliminación produce el efecto espejo: la ventana se contrae y se omite un elemento.
A continuación se presenta un endpoint de offset correcto en TypeScript con Express y pg 8.22.x, ejecutándose en Node 24 (Active LTS):
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
import express from "express";
import { Pool } from "pg";
const pool = new Pool();
const app = express();
interface OffsetPage<T> {
data: T[];
pagination: {
page: number;
limit: number;
has_next_page: boolean;
};
}
function clampLimit(raw: unknown): number {
const n = Number(raw) || 20;
return Math.min(Math.max(Math.trunc(n), 1), 100); // clamp 1–100
}
app.get("/posts", async (req, res) => {
const limit = clampLimit(req.query.limit);
const page = Math.max(Number(req.query.page) || 1, 1);
const offset = (page - 1) * limit;
// Fetch limit + 1 to learn has_next_page without a COUNT query
const { rows } = await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2`,
[limit + 1, offset]
);
const has_next_page = rows.length > limit;
const data = rows.slice(0, limit);
const body: OffsetPage<typeof data[number]> = {
data,
pagination: { page, limit, has_next_page },
};
res.json(body);
});
Dos detalles aquí tienen su razón de ser. La función clampLimit limita el tamaño de página a 100 para que un cliente no pueda solicitar limit=1000000 y agotar el servidor. Y recuperar limit + 1 filas permite al endpoint reportar has_next_page sin una segunda consulta COUNT(*) — el mismo truco que usa la implementación con cursor más adelante. Si existe la fila extra, hay al menos una página más.
Paginación por Cursor: La Superficie de la API
La paginación por cursor reemplaza el offset numérico con un token opaco que marca una posición fija en el conjunto de resultados. El cliente envía el token de vuelta para obtener la siguiente página y, como el token se ancla a los valores de ordenación reales de una fila en lugar de a un conteo, las inserciones y eliminaciones en otras partes de la tabla no corrompen la ventana. La contrapartida es la pérdida del acceso aleatorio a páginas: no existe la “página 7”, solo “la página después de este cursor”.
Cursor y keyset no son sinónimos, y confundirlos es el error más común en los artículos sobre paginación. Un cursor es el token opaco que tu API devuelve al cliente, ocultando la estrategia de paginación; keyset es la técnica SQL — un predicado compuesto WHERE (sort_col, id) < (val, id) — que hace que la paginación por cursor sea rápida al permitir que la base de datos use una búsqueda por índice en lugar de escanear y descartar filas. Puedes implementar una API de cursor con una consulta de offset por debajo (no lo hagas — hereda la ralentización por offset profundo) o con una consulta keyset por debajo (hazlo). El cursor es la interfaz; keyset es el motor eficiente.
El token en sí es simplemente una posición codificada — típicamente la columna de ordenación y un desempate, codificados en base64 para que los clientes lo traten como opaco y no dependan de su formato interno. Mantenlo opaco para poder cambiar su contenido posteriormente sin interrumpir a los clientes durante el recorrido.
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
interface CursorPage<T> {
data: T[];
pagination: {
next_cursor: string | null;
has_more: boolean;
};
}
type Cursor = { created_at: string; id: number };
function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString("base64url");
}
function decodeCursor(raw: string): Cursor {
return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
}
app.get("/feed", async (req, res) => {
const limit = clampLimit(req.query.limit);
const cursor = req.query.cursor
? decodeCursor(String(req.query.cursor))
: null;
// Keyset predicate: compound row comparison on the sort tuple
const { rows } = cursor
? await pool.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursor.created_at, cursor.id, limit + 1]
)
: await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1`,
[limit + 1]
);
const has_more = rows.length > limit;
const data = rows.slice(0, limit);
const last = data[data.length - 1];
const body: CursorPage<typeof data[number]> = {
data,
pagination: {
has_more,
next_cursor:
has_more && last
? encodeCursor({ created_at: last.created_at, id: last.id })
: null,
},
};
res.json(body);
});
El endpoint decodifica el cursor en una tupla (created_at, id), ejecuta una consulta keyset contra ella, recupera limit + 1 para calcular has_more, y codifica el siguiente cursor a partir de la última fila devuelta. Sin COUNT(*), sin offset, sin escaneo de filas descartadas.
Paginación Keyset: El Predicado Compuesto
La paginación keyset es la técnica de base de datos que hace que la paginación por cursor sea rápida: en lugar de un offset, filtra sobre la propia clave de ordenación mediante un predicado compuesto. PostgreSQL soporta comparaciones de valores de fila, por lo que (created_at, id) < ($1, $2) compara la tupla lexicográficamente y permite que un índice compuesto coincidente busque directamente en el límite.
El desempate no es opcional. created_at por sí solo no es único — dos publicaciones pueden compartir una marca de tiempo al milisegundo — y una ordenación por una columna no única produce un orden no determinista, lo que significa que un cursor anclado a created_at podría omitir o repetir filas que comparten ese valor. Añadir la clave primaria (id) hace que la tupla de ordenación sea única y el límite determinista.
-- PostgreSQL 18
-- Composite index matching the sort tuple, including its direction.
CREATE INDEX idx_posts_cursor ON posts (created_at DESC, id DESC);
-- Keyset query: compound predicate seeks into the index.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
La dirección del índice importa. Según la documentación de PostgreSQL sobre índices y ORDER BY, un índice B-tree puede escanearse en cualquier dirección, pero hacer coincidir el orden declarado del índice con el ORDER BY de la consulta permite al planificador satisfacer tanto el predicado como la ordenación con un único escaneo de índice. Ejecuta EXPLAIN ANALYZE en ambas formas sobre una tabla grande y la diferencia estructural es visible en el plan: la consulta con OFFSET 500000 muestra al servidor calculando y descartando las filas iniciales, mientras que la consulta keyset muestra un escaneo de índice con un bajo costo de inicio que devuelve únicamente las filas que necesita. La diferencia de costo es estructural, no accidental — se deriva directamente de la semántica de OFFSET documentada anteriormente.
Page Tokens: Ocultando la Estrategia
Los page tokens son la variante de estrategia opaca de la paginación por cursor, utilizada de forma más limpia por las guías de diseño de API de Google Cloud. Bajo AIP-158, la solicitud lleva page_size y page_token, la respuesta devuelve un next_page_token, y la especificación requiere que los page tokens sean opacos para el cliente. Codificar el estado completo de paginación en el servidor permite cambiar la estrategia interna — de offset a keyset, por ejemplo — sin interrumpir a los clientes que están en medio de un recorrido.
Stripe se menciona frecuentemente junto a Google en este contexto, pero los dos no son el mismo mecanismo, y vale la pena aclarar la distinción. Según la documentación de paginación de Stripe, la API de listado de Stripe usa cursores de ID de objeto (starting_after y ending_before, con limit y un flag has_more), no page tokens opacos; su API de búsqueda es la que devuelve un token opaco next_page. Por tanto, el panorama preciso es el siguiente: Google Cloud usa page tokens opacos, los endpoints de listado de Stripe usan cursores de ID de objeto, Shopify usa conexiones basadas en cursor a través de su API GraphQL de administración, y la API REST de GitHub pagina mediante el encabezado Link — basado en número de página en la mayoría de los endpoints, con cursor (before/after) solo en algunos específicos. El hilo conductor es el recorrido estilo cursor, no un mecanismo uniforme único.
Conexiones GraphQL Estilo Relay
Las conexiones Relay son la especialización GraphQL de la paginación por cursor, formalizada para que cada campo de lista en un esquema pagine de la misma manera. La Especificación de Conexiones de Cursor de Relay define una conexión como una lista de edges, donde cada edge tiene un node (el elemento) y un cursor (su posición opaca), más un objeto pageInfo que expone hasNextPage, hasPreviousPage, startCursor y endCursor.
query {
posts(first: 10, after: "cursor123") {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
endCursor
}
}
}
El cursor por edge es el mismo concepto de token opaco de las secciones REST, y pageInfo.hasNextPage es el mismo flag has_more derivado del truco de recuperar una fila extra. La estructura es más pesada que un envelope simple { data, pagination }, pero ofrece a los clientes un contrato uniforme: paginar cualquier conexión pasando first/after y leyendo pageInfo. Internamente, una conexión Relay debería seguir respaldándose por una consulta keyset por las mismas razones de rendimiento.
Elegir un Patrón y Evitar los Errores Comunes
Usa por defecto la paginación por cursor respaldada por keyset para cualquier nuevo endpoint de listado, y recurre al offset solo cuando un requisito específico lo justifique. La matriz de decisión:
| Caso de uso | Patrón |
|---|---|
| Feed de scroll infinito, flujo de actividad, notificaciones | Cursor + keyset |
| API de listado pública que podrías querer reimplementar más adelante | Page token (opaco) |
| Campo de lista GraphQL | Conexión Relay (keyset por debajo) |
| Exportación masiva de una tabla grande | Cursor + keyset |
| Tabla de administración con “saltar a página N” | Offset |
| Interfaz de búsqueda que muestra números de página | Offset |
| Datos de referencia pequeños (<10k filas) que cambian raramente | Offset (cualquiera funciona) |
Un conjunto de buenas prácticas mantiene todos estos patrones correctos en producción:
- Limita el tamaño de página. Restringe
limita un máximo fijo (100 es un techo habitual) para que un cliente no pueda solicitar una página sin límite. - Indexa la tupla de ordenación completa. El índice compuesto debe coincidir con ambas columnas y direcciones del
ORDER BY, o la consulta keyset recurrirá a un escaneo. - Prefiere
has_next_pagesobre recuentos totales. Devolvertotal_counten cada respuesta obliga a ejecutar unCOUNT(*)adicional, cuyo costo en una tabla grande depende del bloat de la tabla y del mapa de visibilidad; derivahas_next_pagede forma económica a partir de la filalimit + 1y reserva los recuentos totales para las interfaces que genuinamente los requieran. - Mantén los cursores opacos. Codifícalos para que los clientes no puedan depender del formato interno, dejándote libre de cambiar la estrategia subyacente.
- Reinicia el cursor al cambiar el filtro u ordenación. Un cursor codifica una posición en un conjunto de resultados ordenado y filtrado específico; cuando el usuario cambia el orden de clasificación o aplica un nuevo filtro, ese cursor no es válido para la nueva consulta y debe descartarse — reinicia a la primera página siempre que cambien los parámetros de ordenación o filtrado. En el cliente, esto significa vincular el estado del cursor al estado del filtro para que un cambio de filtro limpie las páginas acumuladas y vuelva a solicitar los datos.
Los bugs de ventana deslizante y offset profundo son fáciles de pasar por alto en las pruebas porque solo aparecen bajo escrituras concurrentes o a gran profundidad, y varios de ellos nunca llegan a tus logs de errores. El bug de ventana deslizante es invisible en los logs del servidor — la respuesta es un 200 válido con el número correcto de elementos — pero en la reproducción de sesiones lo vemos como un artefacto de renderizado específico: un elemento que ya era visible en el viewport se renderiza de nuevo en el siguiente lote, inmediatamente adyacente a su posición anterior. La ralentización por offset profundo se manifiesta de la misma manera: no como un timeout o un 4xx, sino como un spinner de “cargar más” que nunca se resuelve en un 200 lento que el cliente sigue esperando. Como estos son síntomas de secuencias de interacción y no errores discretos, la reproducción de sesiones suele ser la forma en que los detectamos — viven en la secuencia completa de scroll, solicitud y renderizado, no en una única solicitud fallida.
Conclusión
Recurre a la paginación por cursor respaldada por una consulta keyset en el próximo endpoint de listado que construyas, y trata el offset como la excepción que justificas en lugar del comportamiento predeterminado que heredas — su costo lineal por offset profundo y su inestabilidad de ventana deslizante son estructurales, no configurables. Añade el índice compuesto en tu tupla de ordenación, devuelve has_next_page en lugar de un recuento total, y mantén el cursor opaco para poder cambiar el motor subyacente sin introducir un cambio disruptivo.
Preguntas Frecuentes
¿Puedo añadir paginación por cursor a una API existente basada en offset sin romper a los clientes?
Sí, pero trátalo como un nuevo parámetro en lugar de un reemplazo. Acepta un parámetro de consulta cursor junto a los parámetros existentes page y limit, y enruta las solicitudes que lleven un cursor a la consulta keyset mientras mantienes las solicitudes basadas en página en el camino de offset. Mantén el cursor opaco para que el formato interno quede libre de cambios, y documenta el camino de offset como obsoleto para los endpoints grandes o con muchas escrituras en lugar de eliminarlo de inmediato.
¿Qué ocurre con un cursor cuando se elimina la fila a la que apunta?
Nada se rompe, porque un cursor keyset codifica valores de ordenación en lugar de una referencia a una fila. El predicado compuesto (created_at, id) < (val, id) selecciona todas las filas ordenadas después de esa tupla límite, por lo que la siguiente página se devuelve correctamente incluso si la fila ancla exacta ya no existe. Esta es la ventaja fundamental sobre la paginación por offset, donde una eliminación desplaza todas las posiciones siguientes y provoca que el cliente omita silenciosamente un elemento.
¿Por qué usar JSON codificado en base64 para un cursor en lugar de enviar directamente la marca de tiempo y el ID?
La codificación hace que el cursor sea opaco, lo que señala a los clientes que el token no está pensado para ser analizado ni construido manualmente. Si los clientes leen la marca de tiempo y el ID directamente, se acoplan a tu estrategia de paginación interna y se rompen cuando la cambias. Un token base64 opaco te permite añadir campos, cambiar claves de ordenación o pasar de keyset a page tokens sin ningún cambio en el cliente. La codificación no añade ninguna sobrecarga significativa.
¿Funciona la paginación por cursor cuando se ordena por una columna distinta de created_at, como una puntuación de popularidad?
Sí, siempre que el cursor codifique la misma tupla de ordenación por la que ordena la consulta y termine con un desempate único. Ordenar por una puntuación de popularidad requiere que el predicado y el índice cubran (score, id) y que el cursor lleve ambos valores, ya que las puntuaciones colisionan con mucha más frecuencia que las marcas de tiempo. Sin el desempate único con id, las filas que comparten una puntuación producen un orden no determinista que puede omitir o repetir elementos entre páginas.