Por Performate
Paginación de API bajo carga: cursor vs offset y puntos de dolor en base de datos
Prueba de stress de paginación como los clientes realmente scrollean: offsets profundos vs cursores estables, y detecta hotspots de DB antes de production.
Los clientes móviles recorren cientos de páginas de listas; tu prueba de carga martilleó ?page=1 diez minutos y la diste por buena. La paginación por offset (?page=42&limit=50) obliga a la base a escanear u ordenar filas saltadas—el costo crece con la profundidad. La paginación cursor/keyset (after=<opaque>) mantiene lookups localizados pero mueve complejidad a sort keys estables e iteradores concurrentes. Las pruebas de carga de paginación deben ejercitar páginas profundas y walkers concurrentes, no loops infinitos en la primera pantalla.
En esta guía verás cómo fallan offset y cursor bajo carga, cómo modelar buckets de profundidad en k6 con tags, qué métricas exponen índices faltantes y cuándo elegir escenarios offset vs cursor según tu mix de analytics.
Por qué la página uno oculta el dolor en base de datos
Funcionalmente, la página uno y la cuarenta devuelven la misma forma JSON. Bajo concurrencia:
- Offsets profundos disparan sorts grandes, scans secuenciales o más buffers—la latencia crece de forma no lineal con el número de página.
- Sort keys inestables producen filas duplicadas u omitidas cuando los datos mutan mid-scroll—los clientes reintentan y amplifican carga.
- Carreras de cursor aparecen cuando escritores reordenan el keyspace mientras lectores avanzan tokens—timeouts y 500s se agrupan en profundidad, no al inicio.
Modela escenarios desde analytics de producto: ¿qué fracción llega a página 10+ en búsqueda o feeds? Dimensiona con cuántos usuarios virtuales y think time realista entre páginas (k6 think time y concurrencia).
Los tradeoffs de diseño REST están documentados en overview conceptual (filtering, sorting, pagination); las pruebas de carga demuestran cuál sobrevive tu esquema.
Implementación práctica en k6: buckets de profundidad y dos paths
Impulsa profundidad progresiva en offset y cadenas de tokens en cursor. Etiqueta cada request con page_bucket o cursor_step para que los resúmenes no promedien el desastre en la página 50.
Script de ejemplo (ilustrativo—no listo para producción). API de lista ficticia con ambos estilos para comparar—en producción usa un solo path.
Qué demuestra este ejemplo:
- Rampa offset vía query
pagecon tagsshallow/mid/deep. - Cadena cursor que parsea
nextdel JSON y para con token vacío. - Umbrales separados por bucket para que el SLO de offset profundo difiera del browse superficial.
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE = __ENV.API_BASE || 'https://staging.example.com';
const LIMIT = Number(__ENV.PAGE_LIMIT || 50);
function pageBucket(page) {
if (page <= 3) return 'shallow';
if (page <= 15) return 'mid';
return 'deep';
}
export const options = {
scenarios: {
offset_deep_scroll: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 30 },
{ duration: '6m', target: 60 },
{ duration: '2m', target: 0 },
],
exec: 'offsetWalk',
tags: { pagination: 'offset' },
},
cursor_iterate: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.CURSOR_RPS || 12),
timeUnit: '1s',
duration: '8m',
preAllocatedVUs: 20,
maxVUs: 80,
exec: 'cursorWalk',
tags: { pagination: 'cursor' },
},
},
thresholds: {
'http_req_duration{page_bucket:deep}': ['p(95)<1200', 'p(99)<2000'],
'http_req_duration{page_bucket:shallow}': ['p(95)<350'],
http_req_failed: ['rate<0.01'],
},
};
export function offsetWalk() {
const page = ((__VU - 1) % 40) + 1;
const bucket = pageBucket(page);
const res = http.get(`${BASE}/v1/items?page=${page}&limit=${LIMIT}`, {
tags: { pagination: 'offset', page_bucket: bucket, page: String(page) },
});
check(res, { 'offset 2xx': (r) => r.status >= 200 && r.status < 300 });
sleep(0.6);
}
export function cursorWalk() {
let cursor = __ENV.SEED_CURSOR || '';
for (let step = 0; step < 8; step++) {
const url = cursor
? `${BASE}/v2/items?limit=${LIMIT}&after=${encodeURIComponent(cursor)}`
: `${BASE}/v2/items?limit=${LIMIT}`;
const res = http.get(url, {
tags: { pagination: 'cursor', cursor_step: String(step) },
});
check(res, { 'cursor 2xx': (r) => r.status >= 200 && r.status < 300 });
const body = res.json();
cursor = body?.next || '';
if (!cursor) break;
sleep(0.4);
}
}
Patrones que funcionan
- Mantener
limitconstante mientras aumentas profundidad de offset—saltos no lineales exponen índices compuestos faltantes. - Ejecutar iteradores cursor concurrentes con tokens realistas desde CSV (parametrización k6) o
SharedArray. - Comparar
p95/p99entre tagspage_bucket, no promedios globales (p95 vs p99). - Con churn de escrituras, validar duplicados/omisiones si producción permite actualizaciones durante el scroll.
Antipatrones a evitar
- Probar solo
page=1porque “es el mismo endpoint”. - Páginas aleatorias sin tags de bucket—los resúmenes ocultan el dolor en profundidad.
- Ignorar timeouts de statement que aparecen como 500 esporádicos solo en profundidad.
Pro tip (comando de ejemplo): percentiles etiquetados para revisión de profundidad.
k6 run pagination-mix.js --summary-trend-stats="p(95),p(99)"
Qué demuestra este comando: puedes leer colas shallow vs deep lado a lado en el resumen final con tags consistentes.
Marco de decisión: offset vs cursor bajo prueba
| Síntoma | Suele indicar | Acción |
|---|---|---|
| Latencia crece con magnitud de offset | Índice compuesto faltante / sorts grandes | Revisión de índices; tope de página en política de API |
| 500s esporádicos solo en profundidad | Timeouts de statement, agotamiento de pool | Guardrails DB; menos trabajo por request |
| Tamaños de página inconsistentes en offset | Sort keys inestables | Arreglar keyset; migrar a cursor |
| Duplicados/omisiones bajo carga | Mutaciones durante el walk | Cursor + sort estable; o lecturas snapshot |
| Cursor rápido, offset profundo lento | Esperable—no fusionar SLOs | Umbrales y escenarios separados |
Simula rampas offset si producción aún sirve ?page=N y analytics muestra tráfico profundo relevante.
Simula cadenas cursor si móvil o BFF iteran tokens—incluye walkers concurrentes y churn de escritura cuando aplique.
Separa escenarios si operas ambos estilos durante migración—etiqueta pagination:offset vs pagination:cursor y gatea releases por separado.
Observabilidad, documentación y próximos pasos
Antes del próximo release de API de listas:
- Obtener analytics de distribución de profundidad (página 10+, longitud de cadena de tokens).
- Documentar
limit, sort keys y política de profundidad máxima junto al nombre del escenario. - Etiquetar
page_bucketocursor_stepen cada request. - Alertar cuando
p(99){page_bucket:deep}cruce el SLO de offset mientras shallow siga verde. - Correlacionar picos k6 en profundidad con slow queries y métricas de buffer.
- Archivar JSON de escenario y cursores semilla por build para corridas comparables.
Cómo Performate convierte journeys de paginación en pruebas repetibles
Reescribir pegamento de paginación cada sprint consume tiempo. Importa flujos reales una vez y ajusta profundidad en el editor.
Ejemplo: lista con infinite scroll (migración offset → cursor)
- Importar colección Postman con list, next-page y requests
afterde QA móvil. Problema resuelto: mismos headers y auth que clientes reales. - Duplicar escenarios
offset_scrollycursor_scrollcon tagspagination:offsetypagination:cursor. Problema resuelto: SLOs separados durante migración. - Configurar rampa de VU o RPS al pico de browse; think time entre páginas en el panel. Problema resuelto: scroll realista sin editar sleep en tres repos.
- Adjuntar umbrales en tags de bucket profundo. Problema resuelto: fallos en profundidad antes del lanzamiento.
- Vista de comparación entre releases—filtrar
page_bucket:deep. Problema resuelto: producto ve regresión de profundidad sin releer salida k6. - Exportar k6 para smoke en CI que toque página 1, 15 y una cadena cursor cada noche.
Ese flujo coincide con el cta: escenarios, umbrales y reportes compartibles sin reescribir pegamento cada sprint.
Cierre
Las pruebas de carga de paginación son un problema de profundidad. El costo offset sube con el número de página; la complejidad del cursor se mueve a tokens y estabilidad de sort. Etiqueta buckets, separa escenarios y gatea colas en páginas profundas, no promedios que la página uno embellece.
Ejecuta esta semana tu escenario de lista hasta página 20+ u ocho pasos de cadena cursor—anota qué bucket rompe p99 primero.
¿Listo para optimizar el rendimiento de tu API?
Usa Performate para convertir recorridos de paginación en escenarios k6 repetibles, umbrales y reportes compartibles.