Diseñando paginación eficiente para grandes volúmenes de datos
Compara la paginación por offset, cursor y keyset con implementaciones prácticas: rendimiento, trade-offs y cuándo encaja cada una en tu API.

La paginación parece sencilla hasta que tu tabla alcanza 10 millones de filas. La solución que funciona bien en la página 1 se vuelve dolorosamente lenta en la página 50.000. Elegir la estrategia adecuada depende de las características de tus datos, los patrones de acceso y de si tus usuarios necesitan saltar a páginas arbitrarias o un scroll infinito.
Paginación por offset: la opción familiar
La paginación por offset es la más intuitiva: omite N filas y devuelve el siguiente lote. Se mapea directamente a OFFSET y LIMIT de SQL.
// ❌ Offset pagination — simple but performance degrades
interface OffsetPaginationParams {
page: number;
pageSize: number;
}
interface PaginatedResponse<T> {
data: T[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
async function getOrdersOffset(
params: OffsetPaginationParams
): Promise<PaginatedResponse<Order>> {
const offset = (params.page - 1) * params.pageSize;
// This query gets slower as offset increases
const [data, countResult] = await Promise.all([
db.query(
`SELECT * FROM orders
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`,
[params.pageSize, offset]
),
db.query("SELECT COUNT(*) FROM orders"),
]);
return {
data: data.rows,
page: params.page,
pageSize: params.pageSize,
totalCount: parseInt(countResult.rows[0].count),
totalPages: Math.ceil(
parseInt(countResult.rows[0].count) / params.pageSize
),
};
}
// Page 1: OFFSET 0 → fast (scans 20 rows)
// Page 1000: OFFSET 20000 → slow (scans 20020 rows, discards 20000)
// Page 50000: OFFSET 1000000 → very slow (scans 1000020 rows)La base de datos debe escanear y descartar todas las filas del offset antes de devolver resultados. Con offsets altos, esto significa leer millones de filas para devolver 20.
Paginación por cursor: estable y escalable
La paginación por cursor usa un puntero (típicamente un identificador de fila codificado) para marcar dónde empieza la siguiente página. El rendimiento es constante sin importar qué tan profundo pagines en el conjunto de datos.
interface CursorPaginationParams {
cursor?: string;
limit: number;
direction: "forward" | "backward";
}
interface CursorPaginatedResponse<T> {
data: T[];
nextCursor: string | null;
previousCursor: string | null;
hasMore: boolean;
}
function encodeCursor(id: string, createdAt: Date): string {
const payload = JSON.stringify({ id, createdAt: createdAt.toISOString() });
return Buffer.from(payload).toString("base64url");
}
function decodeCursor(cursor: string): { id: string; createdAt: Date } {
const payload = JSON.parse(
Buffer.from(cursor, "base64url").toString("utf-8")
);
return {
id: payload.id,
createdAt: new Date(payload.createdAt),
};
}
async function getOrdersCursor(
params: CursorPaginationParams
): Promise<CursorPaginatedResponse<Order>> {
const limit = params.limit + 1; // Fetch one extra to detect hasMore
let query: string;
let values: unknown[];
if (params.cursor) {
const { id, createdAt } = decodeCursor(params.cursor);
// Keyset condition — uses index, no scanning
query = `
SELECT * FROM orders
WHERE (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT $1`;
values = [limit, createdAt, id];
} else {
query = `
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT $1`;
values = [limit];
}
const result = await db.query(query, values);
const hasMore = result.rows.length > params.limit;
const data = hasMore
? result.rows.slice(0, params.limit)
: result.rows;
const lastItem = data[data.length - 1];
const firstItem = data[0];
return {
data,
nextCursor: hasMore && lastItem
? encodeCursor(lastItem.id, lastItem.created_at)
: null,
previousCursor: firstItem
? encodeCursor(firstItem.id, firstItem.created_at)
: null,
hasMore,
};
}// ✅ Consistent performance at any depth
// Page 1: WHERE (created_at, id) < (now, max_id) → index seek
// Page 1000: WHERE (created_at, id) < (some_date, some_id) → same index seek
// Page 50000: same performance — always reads exactly limit+1 rowsLa condición compuesta WHERE (created_at, id) < ($2, $3) usa el índice para saltar directamente a la posición correcta. No se escanean ni descartan filas.
Paginación por keyset con orden compuesto
Cuando ordenas por varias columnas, el cursor debe codificar todos los valores de ordenamiento para mantener el orden correcto.
interface SortableColumn {
name: string;
direction: "asc" | "desc";
}
function buildKeysetQuery(
table: string,
sort: SortableColumn[],
cursor: Record<string, unknown> | null,
limit: number
): { query: string; values: unknown[] } {
const orderClause = sort
.map(s => `${s.name} ${s.direction.toUpperCase()}`)
.join(", ");
if (!cursor) {
return {
query: `SELECT * FROM ${table} ORDER BY ${orderClause} LIMIT $1`,
values: [limit + 1],
};
}
// Build compound comparison for cursor position
// (a, b, c) < ($1, $2, $3) for DESC ordering
const columns = sort.map(s => s.name);
const placeholders = sort.map((_, i) => `$${i + 2}`);
const comparison = sort[0].direction === "desc" ? "<" : ">";
const whereClause =
`(${columns.join(", ")}) ${comparison} (${placeholders.join(", ")})`;
const values = [
limit + 1,
...sort.map(s => cursor[s.name]),
];
return {
query: `SELECT * FROM ${table}
WHERE ${whereClause}
ORDER BY ${orderClause}
LIMIT $1`,
values,
};
}Cómo elegir la estrategia adecuada
Cada enfoque de paginación tiene fortalezas y limitaciones claras.
interface PaginationStrategy {
name: string;
performance: string;
randomAccess: boolean;
stableResults: boolean;
bestFor: string[];
avoidFor: string[];
}
const strategies: PaginationStrategy[] = [
{
name: "Offset",
performance: "Degrades linearly with page depth",
randomAccess: true,
stableResults: false,
bestFor: [
"Small datasets (< 100K rows)",
"Admin interfaces with page numbers",
"Rarely accessed deep pages",
],
avoidFor: [
"Large datasets with deep pagination",
"High-concurrency write tables",
"Real-time feeds with frequent inserts",
],
},
{
name: "Cursor (keyset)",
performance: "Constant regardless of depth",
randomAccess: false,
stableResults: true,
bestFor: [
"Infinite scroll / load more UIs",
"Large datasets with sequential access",
"Real-time feeds and timelines",
"APIs consumed by mobile clients",
],
avoidFor: [
"UIs requiring 'jump to page N'",
"Sorting by non-indexed columns",
],
},
];Requisitos de índices en la base de datos
El rendimiento de la paginación depende por completo de una indexación adecuada.
-- For cursor pagination: compound index matching sort order
CREATE INDEX idx_orders_cursor
ON orders (created_at DESC, id DESC);
-- For filtered cursor pagination
CREATE INDEX idx_orders_user_cursor
ON orders (user_id, created_at DESC, id DESC);
-- Check if your index is being used
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE (created_at, id) < ('2023-06-01', 'abc-123')
ORDER BY created_at DESC, id DESC
LIMIT 21;
-- Should show: Index Scan using idx_orders_cursor
-- NOT: Seq Scan or Sort// Validate pagination query plans
async function validatePaginationQuery(
query: string,
values: unknown[]
): Promise<{ usesIndex: boolean; estimatedCost: number }> {
const plan = await db.query(
`EXPLAIN (FORMAT JSON) ${query}`,
values
);
const planNode = plan.rows[0]["QUERY PLAN"][0].Plan;
const usesIndex =
planNode["Node Type"] === "Index Scan" ||
planNode["Node Type"] === "Index Only Scan";
return {
usesIndex,
estimatedCost: planNode["Total Cost"],
};
}Conclusiones clave
La paginación por offset es adecuada para conjuntos pequeños e interfaces de administración donde los usuarios necesitan números de página, pero su rendimiento se degrada linealmente con la profundidad porque la base de datos debe escanear y descartar todas las filas omitidas. La paginación por cursor mantiene un rendimiento constante a cualquier profundidad usando condiciones de keyset indexadas para saltar directamente a la posición correcta, lo que la hace ideal para scroll infinito, APIs móviles y grandes volúmenes de datos. Codifica los valores del cursor de forma opaca para que los clientes no puedan forjar posiciones ni depender de la estructura interna. Sea cual sea la estrategia que elijas, verifica con EXPLAIN ANALYZE que tus consultas usan índices y no escaneos secuenciales, y crea índices compuestos que coincidan exactamente con tu ordenamiento. La diferencia entre una consulta con cursor bien indexada y una consulta por offset sin índice en la página 50.000 es la diferencia entre milisegundos y minutos.


