---
title: "API Pagination Under Load: Cursor vs Offset and Database Pain Points"
description: "Stress-test pagination the way clients really scroll—deep offsets vs stable cursors—and catch DB hotspots before prod."
publishDate: "2026-07-06"
draft: false
keyword: "pagination load testing"
intent: "MOFU"
cta: "Use Performate to turn pagination journeys into repeatable k6 scenarios, thresholds, and shareable reports."
tags: ["k6","load-testing","api-performance","pagination"]
---

Mobile clients scroll through hundreds of list pages; your load test hammered `?page=1` for ten minutes and called it done. Offset pagination (`?page=42&limit=50`) forces databases to scan or sort past skipped rows—cost grows with depth. Cursor/keyset pagination (`after=<opaque>`) keeps lookups localized but shifts complexity to stable sort keys and concurrent iterators. **Pagination load testing** must exercise **deep pages** and **concurrent walkers**, not infinite loops on the first screen.

In this guide you will learn how offset and cursor APIs fail under load, how to model depth buckets in k6 with tags, which metrics expose missing indexes, and when to choose offset vs cursor scenarios for your analytics mix.

## Why page one hides database pain

Functionally, page one and page forty may return the same JSON shape. Under concurrency:

- **Deep offsets** trigger large sorts, sequential scans, or inflated buffer use—latency grows non-linearly with page number.
- **Unstable sort keys** produce duplicate or skipped rows when data mutates mid-scroll—clients retry and amplify load.
- **Cursor races** appear when writers reorder the keyspace while readers advance tokens—timeouts and 500s cluster at depth, not at the head.

Model scenarios from product analytics: what fraction of users reaches page 10+ during search or feeds? Pair sizing with [how many virtual users](/en/blog/how-many-virtual-users-k6) and realistic think time between page fetches ([k6 think time](/en/blog/k6-think-time-concurrency)).

REST pagination design tradeoffs are well documented conceptually ([filtering, sorting, pagination overview](https://www.moesif.com/blog/technical/api-design/REST-API-Design-Filtering-Sorting-and-Pagination/)); load tests prove which tradeoff your schema can survive.

## Practical k6 implementation: depth buckets and dual pagination paths

Drive **progressive depth** for offset APIs and **token chains** for cursor APIs. Tag every request with `page_bucket` or `cursor_step` so summaries do not average away catastrophe at page 50.

**Example script (illustrative—not production-ready).** Fictional list API with both styles for comparison runs—use one path in production scripts.

**What this example demonstrates:**

- **Offset ramp** via `page` query param with buckets `shallow` / `mid` / `deep` tags.
- **Cursor chain** that parses `next` from JSON and stops on empty token—detects broken iterators under load.
- **Separate thresholds** per bucket so deep offset SLOs differ from shallow browse.

```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() {
  // Each VU walks deeper over iterations — simulates scroll sessions
  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);
  }
}
```

**Patterns that work**

- Hold `limit` constant while increasing offset depth—non-linear latency jumps expose missing composite indexes.
- Run **concurrent cursor iterators** with realistic token streams from CSV ([k6 parameterization](/en/blog/k6-parameterization-csv-json)) or [`SharedArray`](https://k6.io/docs/javascript-api/k6-data/sharedarray/).
- Compare `p95`/`p99` across `page_bucket` tags, not global averages ([p95 vs p99](/en/blog/p95-vs-p99-latency)).
- Under write churn, validate duplicate/skip behavior if production allows live updates during scroll.

**Anti-patterns to avoid**

- Testing only `page=1` because “pagination is the same endpoint.”
- Using random page numbers without bucket tags—summaries hide depth pain.
- Ignoring DB statement timeouts that surface as sporadic 500s only at depth.

**Pro tip (example command):** print tagged percentile trends for depth review.

```bash
k6 run pagination-mix.js --summary-trend-stats="p(95),p(99)"
```

**What this command demonstrates:** you can read shallow vs deep bucket tails side by side in the end summary when tags are consistent.

## Decision framework: offset vs cursor under test

| Symptom | Often indicates | Action |
|:---|:---|:---|
| Latency grows with offset magnitude | Missing composite index / large sorts | Index review; cap max page in API policy |
| Sporadic 500s only at depth | Statement timeouts, pool exhaustion | DB guardrails; reduce per-request work |
| Inconsistent page sizes on offset | Unstable sort keys | Fix keyset; migrate toward cursor |
| Duplicates/skips under load | Mutations during walk | Cursor + stable sort; or snapshot reads |
| Cursor fast, offset deep slow | Expected—do not merge SLOs | Separate thresholds and scenarios |

**Simulate offset ramps if** production still serves `?page=N` and analytics show meaningful deep traffic.

**Simulate cursor chains if** mobile or BFF clients iterate tokens—include concurrent walkers and write churn when applicable.

**Split scenarios if** you operate both styles during migration—tag `pagination:offset` vs `pagination:cursor` and gate releases independently.

## Observability, documentation, and next steps

Before the next list API release:

- [ ] Pull analytics for depth distribution (page 10+, token chain length).
- [ ] Document `limit`, sort keys, and max depth policy beside scenario names.
- [ ] Tag `page_bucket` or `cursor_step` on every request.
- [ ] Alert when `p(99){page_bucket:deep}` crosses offset SLO while shallow stays green.
- [ ] Correlate k6 depth spikes with DB slow-query logs and buffer metrics.
- [ ] Archive scenario JSON and seed cursors per build for comparable runs.

## How Performate turns pagination journeys into repeatable load tests

Rewriting pagination glue every sprint wastes time. Import real client flows once and tune depth in the editor.

**Example: infinite-scroll product list (offset + cursor migration)**

1. **Import the Postman collection** with list, next-page, and cursor `after` requests from mobile QA. *Problem solved:* same headers and auth as real clients.
2. **Duplicate scenarios** `offset_scroll` and `cursor_scroll` with tags `pagination:offset` and `pagination:cursor`. *Problem solved:* separate SLOs during API migration.
3. **Set VU ramp or RPS** to match peak browse traffic; add think time between page fetches in the scenario panel. *Problem solved:* scroll realism without editing sleep constants in three repos.
4. **Attach thresholds** on deep bucket tags (stricter shallow, looser only if product accepts it). *Problem solved:* failures surface at depth before launch.
5. **Run comparison view** between releases—filter `page_bucket:deep`. *Problem solved:* product sees depth regression without relearning k6 output format.
6. **Export k6** for CI smoke that hits page 1, page 15, and one cursor chain nightly.

That workflow matches the post `cta`: repeatable pagination scenarios, thresholds, and shareable reports without rewriting glue each sprint.

## Closing takeaway

**Pagination load testing** is a depth problem. Offset cost climbs with page number; cursor complexity moves to tokens and sort stability. Tag buckets, split scenarios, and gate tails at deep pages—not averages that page one flatters.

Run this week’s list scenario through page 20+ or an eight-step cursor chain—note which bucket blows `p99` first.

[Try Performate free](https://performate.app) | [Book a demo](/demo) | [k6 scenarios](https://k6.io/docs/using-k6/scenarios/)
