
September 12, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
Every list endpoint eventually faces the same design choice: API Pagination: Cursor vs Offset. Offset feels natural because SQL uses LIMIT and OFFSET, and most tutorials start there. Cursor pagination looks harder until you watch offset lists skip rows, duplicate entries, or crawl under load on a production API development project. This guide compares both patterns with copy-paste Laravel 13 examples, response shapes, and the decision rules I use on client APIs.
What is offset pagination in a REST API?
Offset pagination answers: “Give me page N with M items.” The client sends ?page=3&per_page=20. The server translates that to OFFSET 40 LIMIT 20 in SQL or an equivalent skip in MongoDB.
The pattern is easy to document and easy to test. You can jump to any page, show “Page 5 of 200,” and wire a familiar UI. For admin panels with modest tables, offset is often enough.
The cost shows up in production. Deep offsets force the database to scan and discard earlier rows. On PostgreSQL 18 or MySQL 9.7, OFFSET 100000 still reads a large prefix of the index. Response time grows with page depth even when you return only twenty rows.
Typical offset request and response
GET /api/v1/orders?page=3&per_page=20
{
"data": [ /* 20 order objects */ ],
"meta": {
"current_page": 3,
"per_page": 20,
"total": 1542,
"last_page": 78
},
"links": {
"first": "/api/v1/orders?page=1&per_page=20",
"prev": "/api/v1/orders?page=2&per_page=20",
"next": "/api/v1/orders?page=4&per_page=20",
"last": "/api/v1/orders?page=78&per_page=20"
}
} Laravel 13 ships offset pagination out of the box. A controller method like this is enough for internal tools:
public function index(Request $request)
{
$orders = Order::query()
->latest('id')
->paginate($request->integer('per_page', 20));
return OrderResource::collection($orders);
} That works until the list is large, live, or consumed by mobile clients that sync incrementally. Then offset becomes a source of bugs rather than convenience.
How does cursor pagination work in REST APIs?
Cursor pagination answers: “Give me the next M items after this marker.” The client sends ?limit=20&cursor=eyJpZCI6MTA0Mn0. The server decodes the cursor, applies a range filter, and returns a new cursor for the following page.
The cursor is usually an opaque, base64-encoded payload. It might hold a primary key, a timestamp, or a composite sort key. The client must not parse it. Treat it like a bookmark the server issued.
Under the hood this is keyset pagination. Instead of skipping N rows, you filter: “rows where (created_at, id) > (?, ?) ordered by created_at, id.” That pattern uses indexes efficiently and stays stable when new rows arrive at the top of the feed.
Cursor response shape
GET /api/v1/orders?limit=20&cursor=eyJpZCI6MTA0Mn0
{
"data": [ /* 20 order objects */ ],
"meta": {
"limit": 20,
"has_more": true
},
"links": {
"next": "/api/v1/orders?limit=20&cursor=eyJpZCI6MTA2Mn0"
}
} Notice what is missing: total count and last page number. Computing COUNT(*) on huge tables is expensive. Many public APIs—including patterns described in the JSON:API pagination specification—omit totals for cursor feeds and expose only next/previous links.
On a production Laravel application I often implement cursor pagination manually when the default paginator is offset-only:
public function index(Request $request)
{
$limit = min($request->integer('limit', 20), 100);
$cursor = $request->string('cursor')->toString();
$query = Order::query()->orderBy('id');
if ($cursor !== '') {
$decoded = json_decode(base64_decode($cursor), true);
$query->where('id', '>', $decoded['id']);
}
$orders = $query->limit($limit + 1)->get();
$hasMore = $orders->count() > $limit;
$items = $orders->take($limit);
$nextCursor = $hasMore
? base64_encode(json_encode(['id' => $items->last()->id]))
: null;
return response()->json([
'data' => OrderResource::collection($items),
'meta' => ['limit' => $limit, 'has_more' => $hasMore],
'links' => ['next' => $nextCursor
? url("/api/v1/orders?limit={$limit}&cursor={$nextCursor}")
: null],
]);
} For composite sorts—say published_at DESC, id DESC—encode both fields in the cursor and mirror that order in the WHERE clause. Mismatch between sort and filter is a common bug I have debugged on live feeds.
When should you choose API Pagination: Cursor vs Offset?
Neither pattern wins everywhere. The right choice depends on access pattern, dataset size, and whether the client needs totals or arbitrary page jumps.
| Criterion | Offset pagination | Cursor pagination |
|---|---|---|
| Jump to page 47 | Native—send ?page=47 | Not supported—you walk forward with tokens |
| Deep pages (10k+ rows) | Slow—large OFFSET scans | Fast—indexed range filter |
| Live / mutating data | Duplicates and gaps common | Stable forward traversal |
| Total count / last page | Natural fit | Often omitted or approximated |
| Public mobile feed sync | Poor fit | Standard choice |
| Admin CRUD table | Good fit with indexes | Usually unnecessary |
| Implementation effort | Low in Laravel, SQL, ORMs | Medium—encode/decode cursors |
Verdict: Use offset for bounded admin lists where users need page numbers and totals. Use cursor for activity feeds, webhooks outboxes, chat history, and any API where clients scroll forward through data that changes between calls. Hybrid APIs sometimes expose both: offset for back-office, cursor for public sync—document each clearly in your API versioning strategy.
On directory platforms like Lawyers Pokhara, offset pagination suits searchable lawyer listings with modest page depth. On booking systems such as Adventure Third Pole Trek, cursor feeds work better for “load more departures” on mobile where inventory shifts daily.
- Small static catalog (< 5k rows): offset is fine if indexed.
- Infinite scroll UI: cursor with
has_moreflag. - Export / reporting: cursor or keyset batch jobs, not deep offset.
- Third-party SDK: follow vendor style—Stripe documents cursor-based lists in their pagination guide.
How do you implement pagination correctly in Laravel 13?
Laravel’s built-in paginate() method uses offset pagination. For cursor support, evaluate cursorPaginate() on Eloquent queries where your sort column is unique or paired with a tie-breaker ID.
- Define a stable sort:
orderBy('created_at')->orderBy('id'). - Add a composite index matching that order on MySQL or PostgreSQL 18.
- Call
cursorPaginate(20)and return theCursorPaginatorin a resource. - Cap
per_pageorlimitserver-side—never trust the client alone. - Apply rate limiting on list endpoints to block aggressive scraping.
- Document parameters in OpenAPI or Scribe output so SDK consumers know which style you expose.
public function index(Request $request)
{
$perPage = min($request->integer('per_page', 20), 100);
$bookings = Booking::query()
->orderBy('starts_at')
->orderBy('id')
->cursorPaginate($perPage);
return BookingResource::collection($bookings);
} Pair pagination with consistent filtering. If the client passes ?status=confirmed, every cursor must be evaluated against the same filter set. Changing filters should reset the cursor, not reuse an old token.
For APIs I ship through custom software development engagements, I standardise envelope fields across resources: data, meta, and links. That matches guidance in REST API design best practices and keeps mobile and web clients aligned.
Validate cursor payloads defensively. Reject malformed base64, unknown keys, or cursors older than your retention window. Return 400 Bad Request with a clear error code—not a 500 from a database exception.
Indexing checklist for both patterns
Offset still needs indexes on sort and filter columns. Without them, even page 1 becomes a full table scan. Cursor pagination absolutely requires an index that matches your ORDER BY clause.
/* MySQL 9.7 — booking feed */
CREATE INDEX idx_bookings_starts_id ON bookings (starts_at, id);
/* Partial filter example */
CREATE INDEX idx_orders_confirmed ON orders (created_at, id)
WHERE status = 'confirmed'; Run EXPLAIN on representative queries before launch. A list endpoint that scans millions of rows will fail silently until traffic spikes during a sale or campaign.
What are common API pagination mistakes in production?
Most pagination bugs are design mistakes, not framework gaps. These show up repeatedly on APIs I audit or maintain.
Unbounded page size
Allowing ?per_page=99999 invites memory spikes and timeouts. Cap at 50 or 100 and document the limit. Return 422 when exceeded.
Exposing internal IDs without signing cursors
Encoding raw integer IDs in plain base64 leaks sequence information. For sensitive resources, sign cursors with HMAC or use UUID sort keys. At minimum, document that cursors are opaque and not constructible.
Mixing pagination styles on one endpoint
Supporting both ?page= and ?cursor= on the same route confuses clients and doubles test matrices. Pick one primary style per endpoint version. If you must migrate, version the path: /api/v2/orders with cursor only.
Ignoring total count cost
Front-end teams often demand “Page X of Y.” Computing exact totals on multi-million-row tables can add seconds per request. Offer approximate counts, cache totals briefly, or restrict exact totals to admin roles.
No stable sort under duplicates
Sorting only by created_at fails when two rows share the same timestamp. Always add a unique tie-breaker—typically id—as Stripe and other large APIs document in their list endpoints.
Security belongs in the same conversation. Pagination parameters belong in your threat model alongside auth and API security checklist items. Cursor tampering and deep offset scraping are both abuse vectors.
When building SDKs, hide pagination mechanics behind iterators. Consumers call for await (const page of client.orders.list()) instead of hand-rolling cursor URLs. That approach aligns with SDK design for public APIs and cuts support tickets.
Test pagination with concurrent writes. Script inserts while a test client walks pages. Offset tests should expose duplicates; cursor tests should not. Capture fixtures with a JSON formatter during QA so diffs stay readable in CI logs.
For Laravel-specific depth—including simplePaginate(), resource wrappers, and header-based links—see building RESTful APIs with Laravel and Laravel API best practices. Idempotent writes pair naturally with cursor consumers; read idempotency keys if webhooks replay the same event.
On eCommerce APIs like those behind Quick And Easy Nepalese Grocery, product filters plus cursor pagination keep mobile catalog scrolling responsive. Order history for logged-in users can stay offset-based with modest limits because shoppers rarely jump deep.
Document breaking changes when switching styles. Mobile apps pinned to offset will break if you swap query parameters without a version bump. Follow your API deprecation and sunset process and give SDK users a migration window.
Monitor list endpoint latency by page depth. If p95 latency climbs with page value, you have confirmed an offset problem. If cursor decode errors spike, inspect tampered tokens or expired signing keys.
Auth matters too. Sanctum-protected mobile feeds and Passport-backed partner APIs both need pagination scoped to the authenticated principal. Never encode a user ID in a cursor without verifying it on decode. Cross-reference Passport vs Sanctum when choosing token types for paginated resources.
Before production load tests, run testing and optimization passes on your heaviest list queries. Pagination looks trivial in development and expensive at scale.
Key Takeaways
- Offset pagination is simple and supports page numbers; cursor pagination is stable and scales for forward-only feeds.
- Always pair sort columns with a unique tie-breaker ID and a matching composite database index.
- Cap
limitorper_pageserver-side and treat cursors as opaque, server-issued tokens. - Use offset for admin CRUD with totals; use cursor for live data, infinite scroll, and deep lists.
- Version your API when changing pagination style—do not mix
pageandcursoron one endpoint silently. - Load-test list endpoints with concurrent inserts to catch duplicate and skip bugs before launch.
People Also Ask
Is cursor pagination always faster than offset?
Not always on shallow pages. Page 1 with a small offset is cheap. Cursor wins on deep pages and large tables because it avoids scanning discarded rows. The bigger win is consistency under live data, not raw speed on page one.
Can you combine offset and cursor in one API?
You can expose different endpoints or versions for different clients. Avoid supporting both parameter styles on the same route—it doubles edge cases and confuses generated SDKs. Prefer /api/v2/ for cursor feeds when migrating.
How do you paginate sorted by non-unique fields?
Add a unique secondary sort key, usually the primary key ID. Encode both values in the cursor and filter with a tuple comparison: (published_at, id) > (?, ?). Never cursor-paginate on a non-unique column alone.
Does Laravel 13 support cursor pagination natively?
Yes. Eloquent provides cursorPaginate() for keyset-style pages on ordered queries. Offset remains available via paginate() and simplePaginate(). Pick the method that matches your client’s navigation model.
Ship list endpoints that stay correct under load
API Pagination: Cursor vs Offset is not a stylistic debate. It is a contract choice that affects correctness, performance, and how safely mobile clients sync. Start with offset where totals and page jumps matter. Move to cursor when lists grow, data shifts between requests, or deep pages show up in your slow-query log. If you want an audit of existing list routes—or a Laravel 13 API built with the right pagination from day one—contact us or explore recent client portal work where document lists and activity feeds demanded stable cursor patterns.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

