Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

API Pagination: Cursor vs Offset

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.

API Pagination: Cursor vs OffsetOffset paginationpage + per_pageSQL LIMIT / OFFSETCursor paginationafter cursor tokenWHERE id > last_seenClient asks for next chunkOffset jumps by number — Cursor continues from markerSame JSON envelope, different stability trade-offs
API Pagination: Cursor vs Offset — two ways to request the next slice of a list endpoint

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.

Offset pagination under live dataPage 1New rowinsertedPage 2Duplicateor skipOFFSET shifts when rows are added or removedUser sees the same item twice or misses one entirelyFix: cursor token tied to last seen sort keyNext request continues after marker — not after row count
Why API Pagination: Cursor vs Offset matters — offset pages drift when the underlying dataset changes between requests

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.

CriterionOffset paginationCursor pagination
Jump to page 47Native—send ?page=47Not supported—you walk forward with tokens
Deep pages (10k+ rows)Slow—large OFFSET scansFast—indexed range filter
Live / mutating dataDuplicates and gaps commonStable forward traversal
Total count / last pageNatural fitOften omitted or approximated
Public mobile feed syncPoor fitStandard choice
Admin CRUD tableGood fit with indexesUsually unnecessary
Implementation effortLow in Laravel, SQL, ORMsMedium—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_more flag.
  • 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.
Cursor pagination request flowMobile clientlimit + cursorREST APIdecode tokenService layerbuild queryMySQL 9.7index range scanSELECT ... WHERE (sort_key) > cursor ORDER BY sort_key LIMIT n+1Extra row detects has_more without COUNT queryResponse: data[], meta.has_more, links.next with new cursorClient stores opaque token — never constructs cursors locally
Cursor pagination flow — indexed keyset queries return the next stable chunk without OFFSET scans

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.

  1. Define a stable sort: orderBy('created_at')->orderBy('id').
  2. Add a composite index matching that order on MySQL or PostgreSQL 18.
  3. Call cursorPaginate(20) and return the CursorPaginator in a resource.
  4. Cap per_page or limit server-side—never trust the client alone.
  5. Apply rate limiting on list endpoints to block aggressive scraping.
  6. 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.

Choose pagination patternList endpoint designNeed jump to page N?and exact total count?YesUse offsetadmin tablesNoLive or hugedataset?Use cursorfeeds and syncKeep offset ifrows stay small
Decision guide for API Pagination: Cursor vs Offset — match pattern to client navigation and data volatility

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 limit or per_page server-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 page and cursor on 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

Offset pagination answers “give me page N with M items.” The client sends parameters like page=3 and per_page=20; the server translates that to SQL LIMIT and OFFSET or an equivalent skip. Responses usually include current_page, total, last_page, and first/prev/next/last links. It is easy to document, test, and wire to familiar “Page 5 of 200” UIs—ideal for modest admin tables.

Cursor pagination returns the next chunk after a server-issued bookmark, not a page number. The client sends limit and an opaque cursor; the server decodes it and applies a keyset filter such as rows where id is greater than the last seen value. Responses expose has_more and a next link, often without total count. It stays stable when rows are inserted or deleted between requests.

Choose cursor pagination for live or mutating datasets, infinite scroll, mobile sync feeds, chat history, webhooks outboxes, and deep lists where clients only move forward. Choose offset when users need arbitrary page jumps, exact totals, and last-page numbers—typical admin CRUD with bounded depth. Small static catalogs under roughly five thousand indexed rows can stay offset; public feeds and inventory that shifts daily benefit from cursor.

No. Shallow offset pages are cheap; cursor is not automatically faster on page one. Cursor wins on deep pages and large tables because keyset filters use indexes instead of scanning and discarding rows skipped by OFFSET. The main production advantage is consistency when data changes between requests, not raw speed on the first page.

Yes. Laravel 13’s Eloquent paginate() and simplePaginate() use offset pagination out of the box. For keyset-style lists, use cursorPaginate() on a query with a stable orderBy, such as orderBy starts_at then orderBy id. Return the CursorPaginator through an API resource and cap per_page server-side. Manual cursor encode/decode is also common when you need a custom response envelope.

Avoid supporting both page and cursor query parameters on one route—it confuses clients, doubles test cases, and breaks generated SDKs. If different consumers need different styles, expose separate endpoints or version the API, for example /api/v2/orders with cursor only. Hybrid products sometimes use offset for back-office admin and cursor for public mobile sync, documented clearly per route.

Between two requests the underlying list can change: new rows insert at the top or rows delete mid-list. Offset math assumes a fixed snapshot—page 3 always skips the first forty rows. If ten new rows arrive after the client loaded page 2, the next offset page may repeat items the client already saw or skip rows entirely. Cursor pagination avoids that by filtering from the last seen position forward.

Exact COUNT(*) on multi-million-row tables is expensive and can add seconds per request. Many cursor feeds, including patterns aligned with JSON:API pagination guidance, return only data, limit, has_more, and next/previous links. Front-end teams may want “Page X of Y,” but production APIs often offer approximate counts, brief caching, or restrict exact totals to admin roles instead of computing them on every scroll.

Never cursor-paginate on a non-unique column alone—two rows can share the same timestamp and order becomes unstable. Add a unique tie-breaker, typically the primary key id, in both ORDER BY and the cursor payload. For composite sorts such as published_at DESC, id DESC, encode both fields and filter with a tuple comparison. Mismatch between sort order and WHERE clause is a common production bug on live feeds.

Offset still needs indexes on sort and filter columns; without them even page one can full-scan. Cursor pagination requires a composite index matching your ORDER BY, for example idx_bookings_starts_id on starts_at and id in MySQL 9.7 or PostgreSQL 18. Partial indexes help filtered lists. Run EXPLAIN on representative queries before launch—a list endpoint that scans millions of rows fails quietly until traffic spikes.

Recurring issues include unbounded per_page or limit, mixing page and cursor on one endpoint, sorting without a unique tie-breaker, demanding exact totals on huge tables, and encoding raw integer IDs in plain base64 cursors that leak sequence information. Also common: changing filters without resetting the cursor, and skipping load tests with concurrent inserts that expose offset duplicates. Cap limits at fifty or one hundred and return 422 when exceeded.

Define a stable sort with a tie-breaker id and a matching composite index. Use cursorPaginate with a capped per_page, or manually fetch limit plus one rows, set has_more, and base64-encode an opaque cursor. Keep envelope fields consistent: data, meta, and links. Apply the same filters on every request; changing filters should reset the cursor. Validate malformed cursors and return 400 Bad Request instead of a database 500.

Treat pagination in your threat model alongside auth. Cap page size server-side, apply rate limiting on list endpoints to block deep offset scraping, and reject tampered or malformed cursor tokens. For sensitive resources, sign cursors with HMAC rather than exposing constructible raw IDs. Never encode a user ID in a cursor without verifying it on decode. Scope paginated results to the authenticated principal on Sanctum or Passport protected routes.

Offset fits bounded admin CRUD where staff need page numbers, jump-to-page navigation, and totals—internal tools, modest searchable directories, and order history views where users rarely go deep. Laravel’s paginate on an indexed latest id query is enough for many back-office screens. If p95 latency climbs as page number increases, you have confirmed an offset scaling problem and should evaluate cursor for that consumer or dataset.

Do not silently swap query parameters on an existing route—mobile apps pinned to page and per_page will break. Version the path, ship cursor-only on /api/v2/, document parameters in OpenAPI or Scribe, and follow your API deprecation and sunset process with a migration window. Monitor cursor decode errors and list-endpoint latency by page depth. Load-test with concurrent writes: offset should expose duplicates in tests; cursor forward traversal should remain stable.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: