Skip to main content
Jul 6, 2026pagination load testing

By Performate

API Pagination Under Load: Cursor vs Offset and Database Pain Points

Stress-test pagination the way clients really scroll—deep offsets vs stable cursors—and catch DB hotspots before prod.

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 and realistic think time between page fetches (k6 think time).

REST pagination design tradeoffs are well documented conceptually (filtering, sorting, pagination overview); 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.
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) or SharedArray.
  • Compare p95/p99 across page_bucket tags, not global averages (p95 vs p99).
  • 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.

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

SymptomOften indicatesAction
Latency grows with offset magnitudeMissing composite index / large sortsIndex review; cap max page in API policy
Sporadic 500s only at depthStatement timeouts, pool exhaustionDB guardrails; reduce per-request work
Inconsistent page sizes on offsetUnstable sort keysFix keyset; migrate toward cursor
Duplicates/skips under loadMutations during walkCursor + stable sort; or snapshot reads
Cursor fast, offset deep slowExpected—do not merge SLOsSeparate 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 | Book a demo | k6 scenarios

Ready to optimize your API performance?

Use Performate to turn pagination journeys into repeatable k6 scenarios, thresholds, and shareable reports.

← Back to all posts