Por qué cada colección debe estar paginada
Un endpoint de lista sin un límite es una promesa que no puedes cumplir. El código puede ser correcto el día que se despliega, cuando la tabla tiene unos pocos cientos de filas, y convertirse silenciosamente en un problema a medida que la tabla crece. Un SELECT * sobre una tabla de un millón de filas serializará decenas de megabytes, los mantendrá en memoria y los enviará a un cliente que probablemente solo quería los primeros veinte elementos.
El fallo no es gradual. Al alcanzar cierto tamaño de tabla, el endpoint cruza un umbral: la memoria se dispara, el event loop se bloquea, las solicitudes expiran, los reintentos se acumulan y una sola ruta lenta tumba todo el servicio. Un atacante no autenticado ni siquiera necesita un bug, solo una URL.
La solución es establecer un límite estricto en cada colección:
- Un tamaño de página predeterminado para que los clientes reciban una respuesta útil sin tener que configurarlo.
- Un tamaño de página máximo para que ningún cliente pueda solicitarlo todo.
- Un orden estable para que las páginas no se solapen ni se salten elementos.
- Un token de continuación para que el cliente pueda solicitar el siguiente segmento.
Incluso los endpoints que creas que son pequeños merecen un límite. Las tablas crecen, se realizan importaciones y el endpoint que proteges hoy es el que se mantendrá activo mañana.
Paginación por offset y dónde falla
La forma más familiar es LIMIT con OFFSET: devolver limit filas, comenzando después de offset filas.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
Es fácil de entender, se mapea naturalmente a números de página (page * limit) y permite que el cliente salte a cualquier página. Para tablas pequeñas que cambian lentamente es perfectamente adecuada. Para todo lo demás, presenta tres problemas que empeoran a medida que los datos crecen.
Las páginas profundas son costosas. Para devolver las filas después de un offset, la base de datos debe primero generar y descartar cada fila anterior. OFFSET 1000000 lee un millón de filas para devolver veinte. El trabajo crece linealmente con el número de página, por lo que la página 1 es rápida y la página 50,000 no lo es.
La ventana se desplaza. El offset es una posición en una lista que está cambiando. Si se inserta una fila antes de tu posición, la siguiente página repite un elemento; si se elimina una fila, se salta un elemento. El cliente ve duplicados y huecos, y no hay cantidad de reintentos que lo solucione.
El orden puede ser inestable. Si la clave de ordenamiento tiene empates, como ocurre a menudo con created_at, la base de datos tiene libertad para devolver las filas empatadas en cualquier orden. Dos solicitudes para el mismo offset pueden producir resultados diferentes.
El offset no es incorrecto, simplemente no es adecuado para colecciones grandes o que cambian rápidamente. Úsalo para tablas de administración con números de página sobre datos modestos, y recurre a un cursor cuando la lista pueda crecer.
Paginación por cursor: estable por construcción
Un cursor reemplaza el “saltar N filas” por el “comenzar después de esta fila”. El cliente envía un token opaco generado por el servidor, y el servidor lo convierte nuevamente en una posición precisa dentro del ordenamiento.
Debido a que el token identifica una fila en lugar de un conteo, las inserciones y eliminaciones en otras partes del conjunto de resultados no pueden desplazarlo. Como el servidor busca directamente esa fila, la profundidad no tiene un costo adicional. Estas dos garantías —estabilidad y costo constante— son exactamente las que el offset no puede proporcionar.
El precio es que un cursor solo se mueve hacia adelante o hacia atrás. No existe la “página 50”. Para feeds, líneas de tiempo, exportaciones y scroll infinito, esto no supone ninguna pérdida. Para una cuadrícula de resultados de búsqueda con páginas numeradas, el offset sigue siendo la opción más natural hasta que el volumen de datos se vuelve muy grande.
Un cursor debe ser opaco para los clientes: codificado en base64url, tratado como una caja negra y devuelto sin cambios. La opacidad permite que el servidor cambie las columnas de ordenamiento más adelante sin romper la compatibilidad con los clientes, y desincentiva la creación manual de tokens.
Paginación por claves (Keyset pagination) en SQL
La paginación por claves es lo que hace un cursor a nivel de base de datos. En lugar de contar, comparas las columnas de ordenación con los valores de la última fila devuelta.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
La forma (created_at, id) < ($1, $2) es una comparación de valores de fila y expresa la lógica de manera limpia: toma las filas cuya clave de ordenación venga estrictamente después de la última. Postgres y la mayoría de las bases de datos relacionales pueden utilizar un índice que coincida con el orden para buscar directamente el punto de partida.
Ese índice no es opcional. La paginación por claves solo es rápida cuando la ordenación está respaldada por un índice sobre las columnas y direcciones exactas utilizadas en ORDER BY.
CREATE INDEX posts_created_id_idx
ON posts (created_at DESC, id DESC);
Dos detalles causan la mayoría de los errores. Primero, la dirección de la comparación debe coincidir con la ordenación: una ordenación DESC utiliza <, una ordenación ASC utiliza >. Segundo, el cursor debe incluir cada columna de ordenación. Si ordenas solo por created_at, los empates hacen que el cursor sea ambiguo, razón por la cual siempre se añade id.
Creación y codificación de un cursor
Un cursor es simplemente el valor de ordenación de la última fila de la página, serializado y codificado. Manténlo pequeño y valídalo al recibirlo de vuelta.
export type Cursor = { createdAt: string; id: string };
export function encodeCursor(cursor: Cursor): string {
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
}
export function decodeCursor(token: string): Cursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
} catch {
throw new Error("invalid_cursor");
}
const value = parsed as Record<string, unknown>;
if (typeof value.createdAt !== "string" || typeof value.id !== "string") {
throw new Error("invalid_cursor");
}
return { createdAt: value.createdAt, id: value.id };
}
El truco de “límite más uno” te indica si existe otra página sin necesidad de realizar un conteo. Solicita limit + 1 filas; si recibes más de limit, significa que hay una página siguiente, y simplemente descartas la fila extra antes de responder.
const rows = await db.query(
`SELECT id, title, created_at
FROM posts
WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1, $2))
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1],
);
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const last = data.at(-1);
const nextCursor = hasMore && last
? encodeCursor({ createdAt: last.created_at, id: last.id })
: null;
Base64url es una codificación, no una firma. Un cliente puede decodificarla y crear una nueva, por lo que nunca debes tratar un cursor como una entrada confiable. Valida cada campo y, si la manipulación de datos es un problema, firma el payload con un HMAC o mantén las claves de ordenación en el servidor y almacena el cursor en Redis.
El envoltorio de respuesta paginada
Devuelve una estructura consistente en cada endpoint de colección. De este modo, los clientes tienen un único patrón para parsear y tú puedes evolucionar la implementación interna sin cambiar el contrato.
{
"data": [
{ "id": "post_1042", "title": "Hello", "createdAt": "2026-09-16T10:00:00Z" }
],
"pagination": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTE2VDEwOjAwOjAwWiIsImlkIjoicG9zdF8xMDQyIn0",
"hasMore": true,
"total": 1284
}
}
data contiene la página y nunca excede el límite. nextCursor es el token para la siguiente página, o null cuando la lista se agota, lo cual es la señal para que el infinite scroll se detenga. hasMore es una conveniencia que evita que los clientes tengan que verificar si es null, y se obtiene gratis gracias a la petición de límite más uno.
total es opcional y debe tratarse como tal. Una página basada en cursor no lo necesita, y calcularlo en cada petición suele ser más costoso que la propia página. Inclúyelo solo cuando la UI muestre genuinamente “1,284 resultados”, y en ese caso, almacénalo en caché o usa una aproximación.
Estabilidad del ordenamiento y desempates
Un cursor solo tiene sentido si el ordenamiento es un orden total: para cualquier par de filas, una debe estar definitivamente antes que la otra. La mayoría de las claves de ordenamiento naturales no lo son. Muchos posts comparten el mismo created_at y la base de datos puede devolverlos en cualquier orden, por lo que un cursor que codifique únicamente el timestamp puede saltarse o repetir filas.
La solución es añadir una columna única, casi siempre la clave primaria, como clave de ordenamiento final.
ORDER BY created_at DESC, id DESC
Ahora el orden es determinista y el cursor (created_at, id) es único. La misma regla se aplica a cualquier ordenamiento: ORDER BY score DESC, id DESC, ORDER BY name ASC, id ASC. El desempate debe ser único y debe formar parte tanto del índice como del cursor.
Las claves de ordenamiento también deben ser estables en el tiempo. Ordenar por updated_at y usarlo en un cursor es una trampa: cuando se edita una fila, su posición de ordenamiento cambia, y un cursor capturado antes de la edición puede apuntar al lugar equivocado. Prefiere claves inmutables como created_at o un id monotónico; y si es imprescindible ordenar por un campo mutable, acepta que los cursors pueden quedar obsoletos.
Los conteos totales son costosos
El conteo total es la parte más solicitada y menos necesaria de la paginación. SELECT count(*) con un filtro debe examinar cada fila coincidente y, en una tabla grande, esto puede tardar más que obtener la página misma.
-- Runs on every request if you are not careful.
SELECT count(*) FROM posts WHERE author_id = $1;
Una página basada en cursor no lo necesita. hasMore responde a la pregunta que el cliente realmente tiene —¿hay más?— sin tocar ni una sola fila adicional. Si la UI realmente necesita un número, elige un enfoque que se ajuste a la precisión que pueda tolerar:
- Omítelo. La mayoría de las interfaces de feed y scroll infinito nunca muestran un total.
- Aproxímalo. Postgres expone
reltuplesenpg_classy el planner puede estimar conEXPLAIN; ambos son rápidos y fallan por unos pocos porcentajes. - Almacénalo en caché. Calcula el conteo según un horario o después de las escrituras y sirve el valor almacenado.
- Mantén un contador. Lleva un conteo actualizado en una tabla de resumen que se actualice dentro de la misma transacción que las escrituras.
- Ponle un límite. Deja de contar al llegar a 1,000 y devuelve “1000+”, lo que limita el costo.
Sea lo que sea que elijas, no ejecutes un count(*) sin caché en cada solicitud de página de una tabla grande.
Tamaño de página: valores predeterminados y límites
Dos números protegen al servidor: un valor predeterminado para cuando el cliente no especifica nada, y un máximo estricto para cuando el cliente solicita demasiado.
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
function parseLimit(raw: string | undefined): number {
const requested = Number.parseInt(raw ?? "", 10);
if (!Number.isFinite(requested) || requested < 1) return DEFAULT_LIMIT;
return Math.min(requested, MAX_LIMIT);
}
Es mejor limitar que rechazar. Un cliente que solicite limit=1000 debería recibir 100 filas y un cursor, no un error 400 que lo obligue a adivinar tus reglas. Valida que limit sea un entero positivo y nunca pases un string del cliente directamente a SQL.
El tamaño de página es un regulador de latencia. Páginas más grandes significan menos viajes de ida y vuelta (round-trips), pero más trabajo por solicitud y más bytes transmitidos. Para interfaces de usuario interactivas, un valor entre 20 y 50 suele ser lo adecuado. Para exportaciones masivas, utiliza un endpoint dedicado con un límite mucho mayor y streaming, en lugar de aumentar el límite en la ruta interactiva.
Parámetros de filtrado y ordenación
La paginación se compone con el filtrado y la ordenación, pero ambos deben manejarse con cuidado ya que cambian el significado de un cursor.
GET /posts?author_id=42&sort=-created_at&limit=20&cursor=eyJjcmVhdGVkQXQiOi...
Crea una lista blanca (whitelist) de campos de ordenación. Nunca interpoles el nombre de una columna proporcionado por el cliente directamente en SQL. Mapea un conjunto de nombres permitidos a expresiones y elige la dirección mediante un prefijo o parámetro explícito.
const SORTS = {
created_at: "created_at",
title: "title",
score: "score",
} as const;
const column = SORTS[sortField] ?? "created_at";
const direction = order === "asc" ? "ASC" : "DESC";
Haz que el cursor coincida con la ordenación. Si la ordenación cambia, el cursor pierde su sentido. Puedes codificar la ordenación dentro del cursor y rechazar cualquier discrepancia, o incluir la ordenación en el payload del cursor y verificarla al decodificarlo. Lo mismo ocurre con los filtros: un cursor proveniente de una lista sin filtrar no debe reutilizarse en una lista filtrada.
Añade cada columna de ordenación al índice. Un índice compuesto en (author_id, created_at DESC, id DESC) sirve tanto para el filtro como para la búsqueda de keyset en una sola estructura, lo cual marca la diferencia entre cargar una página en un milisegundo o en un segundo.
Paginación en búsquedas y agregaciones
Los motores de búsqueda y las agregaciones tienen sus propias reglas. Los backends de búsqueda de texto completo suelen limitar from + size a unos diez mil resultados, ya que el offset profundo también es costoso para ellos. El equivalente a keyset en este caso es un token search_after construido a partir de los valores de ordenación del último resultado.
POST /posts/_search
{
"size": 20,
"sort": [{ "created_at": "desc" }, { "id": "desc" }],
"search_after": ["2026-09-16T10:00:00Z", "post_1042"]
}
Lo ideal es devolver las agregaciones por separado de la página. Calcular los recuentos de facetas para cada coincidencia en cada solicitud es la misma trampa que count(*). O bien calcúlalos una vez y almacénalos en caché, o expón un endpoint dedicado que la UI llame cuando el usuario abra un panel de filtros.
Para los agregados de SQL, se aplica la misma idea de keyset: ordena por una clave de agregado estable, como un bucket de fecha o un id, y utiliza esa clave en el cursor. No pagines un GROUP BY con OFFSET sobre una tabla grande; materializa primero el agregado y luego pagina el resultado materializado.
Eligiendo una estrategia
La mayoría de los equipos solo necesitan una regla: si la colección puede crecer considerablemente o cambiar mientras se está leyendo, usa un cursor; si es pequeña, cambia poco y se muestra como una cuadrícula numerada, el offset es suficiente.
Small table, numbered UI -> offset
Large or fast-changing collection -> keyset cursor
Infinite scroll or mobile feed -> keyset cursor
Search results -> search_after token
Bulk export -> dedicated streaming endpoint
El offset y el cursor pueden coexistir. Una tabla de administración podría ofrecer números de página para navegar y un cursor para un flujo de “exportar todo”. Lo importante es que cada endpoint elija un estilo y lo documente, en lugar de mezclar parámetros de page y cursor de una manera que los clientes no puedan predecir.
Paginación hacia atrás
La paginación hacia adelante es la que suele llevarse toda la atención, pero muchas interfaces también necesitan un botón de “anterior”. La técnica consiste en invertir la comparación y el ordenamiento, obtener una página y luego invertir las filas en el código de la aplicación antes de devolverlas.
async function pageBackward(prev: Cursor, limit: number) {
const rows = await db.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT $3`,
[prev.createdAt, prev.id, limit + 1],
);
// Reverse back into the canonical descending order.
return rows.reverse();
}
Devuelve un prevCursor construido a partir de la primera fila de la página actual junto con un nextCursor construido a partir de la última. Un cliente que navega hacia atrás cambia a la dirección hacia adelante cuando el usuario vuelve a hacer scroll hacia abajo, por lo que los cursores deben ser intercambiables en lugar de estar ligados a una dirección.
Manteniendo la integridad de los cursores
Base64url es una codificación, no una firma. Un cliente puede decodificar un cursor, editarlo y enviarlo de vuelta, por lo que un cursor es una entrada no confiable, exactamente igual que un parámetro de consulta. Valida cada campo al decodificar y rechaza cualquier cosa que no se ajuste a la estructura esperada antes de que llegue a SQL.
Si la manipulación de datos es una preocupación real —por ejemplo, si un cursor transporta un tenant id— fírmalo con un HMAC y verifica la firma en tiempo constante.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.CURSOR_SECRET!;
export function signCursor(payload: string): string {
const mac = createHmac("sha256", secret).update(payload).digest("base64url");
return `${Buffer.from(payload).toString("base64url")}.${mac}`;
}
export function verifyCursor(token: string): string {
const [encoded, mac] = token.split(".");
const expected = createHmac("sha256", secret).update(encoded).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error("invalid_cursor");
}
return Buffer.from(encoded, "base64url").toString("utf8");
}
Una alternativa es mantener los cursores totalmente en el lado del servidor: almacena la posición en Redis bajo un id aleatorio y entrega al cliente únicamente ese id. Esto oculta completamente las columnas de ordenación y permite establecer una expiración, a costa de realizar una búsqueda en cada página.
Un cliente que sigue cursores
Un cursor está diseñado para ser seguido, por lo que el código del cliente es un bucle sencillo: solicitar una página, añadir los datos y continuar mientras nextCursor no sea null. No hay aritmética de páginas ni riesgo de saltarse alguna.
async function fetchAll<T>(path: string): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
do {
const url = new URL(path, "https://api.example.com");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(`request_failed_${res.status}`);
const page = await res.json();
items.push(...page.data);
cursor = page.pagination.nextCursor;
} while (cursor);
return items;
}
El bucle termina en nextCursor === null, razón por la cual ese campo debe establecerse de forma fiable en la página final. Para el scroll infinito, el mismo patrón se ejecuta página por página a medida que un elemento centinela entra en el viewport, y el token se mantiene en el estado del componente en lugar de en la URL.
Paginación y el plan de consulta
La paginación por keyset es rápida solo cuando la base de datos puede utilizar un índice. Confírmalo siempre con EXPLAIN ANALYZE en lugar de darlo por sentado.
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-09-16T10:00:00Z', 'post_1042')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Limit (cost=0.43..8.94 rows=20 width=40)
(actual time=0.021..0.058 rows=20 loops=1)
-> Index Scan Backward using posts_created_id_idx on posts
(cost=0.43..521.10 rows=417 width=40)
(actual time=0.019..0.051 rows=20 loops=1)
Index Cond: (ROW(created_at, id) < ROW('2026-09-16T10:00:00Z'::timestamptz, 'post_1042'))
Execution Time: 0.081 ms
El Index Scan Backward con un Index Cond es el resultado que buscas: la base de datos busca la posición del cursor y se detiene después de veinte filas. Un Seq Scan con un Filter significa que el índice no coincide con el ordenamiento y que la consulta está escaneando toda la tabla en cada página. El índice debe listar las mismas columnas en el mismo orden y dirección que ORDER BY, dejando el criterio de desempate al final.
Probando la paginación
Las propiedades que realmente vale la pena probar son la estabilidad y la terminación, no solo el “happy path”. Una prueba que recorra cada página y verifique que no haya duplicados ni filas faltantes detecta esos errores sutiles de desempate (tie-breaker) que, de otro modo, serían invisibles.
test("cursor pagination never repeats or skips rows", async () => {
const seen = new Set<string>();
let cursor: string | null = null;
do {
const page = await request(app)
.get("/posts")
.query({ limit: 10, cursor: cursor ?? undefined })
.expect(200);
for (const post of page.body.data) {
expect(seen.has(post.id)).toBe(false);
seen.add(post.id);
}
cursor = page.body.pagination.nextCursor;
} while (cursor);
expect(seen.size).toBe(totalPosts);
});
test("rejects a malformed cursor", async () => {
await request(app).get("/posts?cursor=not-a-cursor").expect(400);
});
Prueba también los límites: la primera página sin cursor, la última página donde nextCursor es null, una página más grande que el máximo y un ordenamiento que cambie a mitad del recorrido. Esta última es la prueba que demuestra que tu tie-breaker funciona correctamente.
Mejores prácticas
- Asigna a cada colección un límite predeterminado y un máximo absoluto, y aplica un recorte (clamp) en lugar de rechazar la solicitud.
- Prefiere la paginación por keyset o cursor para cualquier recurso que crezca o cambie.
- Ordena siempre con un desempate único como
id, e inclúyelo tanto en el índice como en el cursor. - Mantén los cursores opacos, codifícalos como base64url y valida cada campo al decodificarlos.
- Solicita
limit + 1para determinarhasMoreen lugar de ejecutar un count. - Devuelve un envoltorio (envelope) consistente con
data,nextCursoryhasMoreen todas partes. - Trata
totalcomo opcional; omítelo, aproxímalo o almacénalo en caché. - Crea una lista blanca (whitelist) de campos y direcciones de ordenamiento, y vincula todos los valores como parámetros.
- Haz que el cursor codifique el contexto de ordenamiento y filtrado para que un token caducado no pueda ser reutilizado.
Errores comunes
- Lanzar un endpoint de lista sin límite y descubrir el problema cuando la aplicación escala.
- Usar
OFFSETpara páginas profundas y observar cómo la latencia aumenta junto con el número de página. - Ordenar por una columna que no es única sin un criterio de desempate, provocando que las filas se repitan o desaparezcan.
- Ordenar por una columna mutable y tratar el cursor como si fuera permanente.
- Pasar un
limit, nombre de columna o dirección proporcionados por el cliente directamente a SQL. - Ejecutar un
count(*)sin caché en cada solicitud de página. - Exponer IDs internos o timestamps sin procesar en un cursor y considerarlo seguro.
- Devolver un array simple sin cursor, obligando a los clientes a adivinar cómo continuar.
- Permitir un
limitde un millón porque la UI nunca lo solicita.
Próximos pasos
La paginación es parte del diseño de una API predecible, por lo que la guía de REST es el complemento natural para aprender sobre la estructura de los recursos, los códigos de estado y las convenciones de consulta. Si quieres evitar recalcular la misma página, la sección de Caching explica cómo servirla desde la memoria, y Connection Pooling ayuda a que cada consulta paginada sea eficiente para la base de datos. Para que esas consultas sean rápidas desde el principio, la guía de PostgreSQL explica los índices compuestos y los planes de consulta en los que se basa la paginación por keyset.