Skip to main content
6 jul 2026pruebas carga paginacion api cursor

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 page con tags shallow / mid / deep.
  • Cadena cursor que parsea next del 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 limit constante 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/p99 entre tags page_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=1 porque “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íntomaSuele indicarAcción
Latencia crece con magnitud de offsetÍndice compuesto faltante / sorts grandesRevisión de índices; tope de página en política de API
500s esporádicos solo en profundidadTimeouts de statement, agotamiento de poolGuardrails DB; menos trabajo por request
Tamaños de página inconsistentes en offsetSort keys inestablesArreglar keyset; migrar a cursor
Duplicados/omisiones bajo cargaMutaciones durante el walkCursor + sort estable; o lecturas snapshot
Cursor rápido, offset profundo lentoEsperable—no fusionar SLOsUmbrales 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_bucket o cursor_step en 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)

  1. Importar colección Postman con list, next-page y requests after de QA móvil. Problema resuelto: mismos headers y auth que clientes reales.
  2. Duplicar escenarios offset_scroll y cursor_scroll con tags pagination:offset y pagination:cursor. Problema resuelto: SLOs separados durante migración.
  3. 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.
  4. Adjuntar umbrales en tags de bucket profundo. Problema resuelto: fallos en profundidad antes del lanzamiento.
  5. Vista de comparación entre releases—filtrar page_bucket:deep. Problema resuelto: producto ve regresión de profundidad sin releer salida k6.
  6. 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.

Try Performate free | Book a demo | k6 scenarios

¿Listo para optimizar el rendimiento de tu API?

Usa Performate para convertir recorridos de paginación en escenarios k6 repetibles, umbrales y reportes compartibles.

← Volver a todas las entradas