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
pagequery param with bucketsshallow/mid/deeptags. - Cursor chain that parses
nextfrom 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
limitconstant 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/p99acrosspage_buckettags, 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=1because “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
| 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_bucketorcursor_stepon 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)
- Import the Postman collection with list, next-page, and cursor
afterrequests from mobile QA. Problem solved: same headers and auth as real clients. - Duplicate scenarios
offset_scrollandcursor_scrollwith tagspagination:offsetandpagination:cursor. Problem solved: separate SLOs during API migration. - 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.
- Attach thresholds on deep bucket tags (stricter shallow, looser only if product accepts it). Problem solved: failures surface at depth before launch.
- Run comparison view between releases—filter
page_bucket:deep. Problem solved: product sees depth regression without relearning k6 output format. - 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.
Ready to optimize your API performance?
Use Performate to turn pagination journeys into repeatable k6 scenarios, thresholds, and shareable reports.