---
title: "Paginación de API bajo carga: cursor vs offset y puntos de dolor en base de datos"
description: "Prueba de stress de paginación como los clientes realmente scrollean: offsets profundos vs cursores estables, y detecta hotspots de DB antes de production."
publishDate: "2026-07-06"
draft: false
keyword: "pruebas carga paginacion api cursor"
intent: "MOFU"
cta: "Usa Performate para convertir recorridos de paginación en escenarios k6 repetibles, umbrales y reportes compartibles."
tags: ["k6", "pruebas-de-carga", "rendimiento-api", "paginacion"]
translationOf: api-pagination-load-testing-cursor-offset
---

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](/es/blog/cuantos-usuarios-virtuales-k6) y think time realista entre páginas ([k6 think time y concurrencia](/es/blog/k6-think-time-concurrencia)).

Los tradeoffs de diseño REST están documentados en overview conceptual ([filtering, sorting, pagination](https://www.moesif.com/blog/technical/api-design/REST-API-Design-Filtering-Sorting-and-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.

```javascript
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](/es/blog/k6-parametrizacion-csv-json)) o [`SharedArray`](https://k6.io/docs/javascript-api/k6-data/sharedarray/).
- Comparar `p95`/`p99` entre tags `page_bucket`, no promedios globales ([p95 vs p99](/es/blog/p95-vs-p99-latencia)).
- 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.

```bash
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_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](https://performate.app) | [Book a demo](/demo) | [k6 scenarios](https://k6.io/docs/using-k6/scenarios/)
