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 Deep Dive

By Kokil Thapa | Last reviewed: September 2026

Every list endpoint forces a choice: cursor pagination vs offset pagination. Offset feels natural because SQL and ORMs support it out of the box. Cursor pagination trades page numbers for stable, constant-time reads on large tables. I've hit both patterns in production—legal document archives, booking feeds, eCommerce catalogs—and the wrong default costs real money after launch. This guide walks through mechanics, trade-offs, Laravel implementation, and how to decide before you ship. For broader API design context, see my notes on Laravel API best practices and API development services.

How does offset pagination work and when does it fail?

Offset pagination uses two parameters: limit (page size) and offset (rows to skip). The database runs LIMIT 20 OFFSET 1000. It must scan and discard 1,000 rows before returning the next 20. For small tables or admin screens where users rarely pass page five, this works fine. The SQL is readable. Every ORM supports it without extra setup.

Problems appear at scale. On a table with millions of rows, page 50,000 forces MySQL or PostgreSQL to process 999,980 rows for 20 results. Query time grows linearly with offset. I've seen timeouts on legal-tech portals where case archives outgrew early estimates. Offset also drifts under concurrent writes. A new row inserted while a user browses shifts items between pages. Users see duplicates or skipped records. For stable feeds at depth, offset breaks down.

You can mitigate offset pain without switching entirely. Keyset pagination on a single indexed column is a middle ground. Covering indexes reduce scan cost. Still, deep offset remains O(offset + limit) unless you change the model. For query tuning basics, see database indexing for performance and MySQL query optimization for slow queries.

Offset Pagination Scan CostSkipped1,000OFFSET rowsReturned20LIMIT rowsDatabase WorkRows scanned: 1,020Rows returned: 20Waste: 98%Cost grows with OFFSET
Cursor pagination vs offset: offset scans and discards rows before the target page, causing linear slowdown at depth

How does cursor pagination solve performance and consistency issues?

Cursor pagination replaces numeric offset with an opaque pointer to the last seen record. Instead of "skip 1,000 rows," the query asks for "20 rows where id > 58392." The database index-seeks to that position. Query time stays near constant regardless of depth. Social feeds and activity timelines use this pattern for a reason.

Cursors also stabilize results. The query anchors to a specific record, not a positional slot. Inserts and deletes between requests do not shuffle items across pages. The trade-off is navigation. There is no native "page 47." Users move next or previous only. That fits infinite scroll and mobile feeds. It frustrates users who need arbitrary jumps or total page counts.

Laravel 13 ships CursorPaginator on PHP 8.3+. Laravel 12 on PHP 8.2 supports the same API. The framework handles encoding, decoding, and query construction. For REST fundamentals, see how to build a REST API in Laravel and the official Laravel cursor pagination documentation.

Implementing cursor pagination in Laravel

Here is a production-ready controller method with a deterministic sort:

<?php

namespace App\Http\Controllers\Api;

use App\Models\CaseFile;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class CaseFileController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $paginator = CaseFile::query()
            ->where('firm_id', $request->user()->firm_id)
            ->orderByDesc('file_date')
            ->orderByDesc('id')
            ->cursorPaginate(20);

        return response()->json([
            'data' => $paginator->items(),
            'meta' => [
                'next_cursor' => $paginator->nextCursor()?->encode(),
                'prev_cursor' => $paginator->previousCursor()?->encode(),
                'per_page' => $paginator->perPage(),
            ],
        ]);
    }
}

Always add a unique tie-breaker column. When sorting by file_date, many rows share the same value. Without a final sort on id, cursors skip or duplicate records. Chain orderBy calls in the same direction for every column except where product rules require otherwise.

OffsetLIMIT 20 OFFSET 1000Full index scanDiscard 1,000 rowsReturn 20 rowsTime: O(offset)CursorWHERE id > 58392 LIMIT 20B-tree index seekJump to positionReturn 20 rowsTime: O(limit)Stable under writes
Offset vs cursor pagination: index seeks keep cursor queries fast while offset cost grows with page depth

What are the practical trade-offs between cursor and offset pagination?

No single strategy wins everywhere. Product navigation, dataset size, and reporting needs drive the choice. Many apps use both—offset on admin routes, cursor on public feeds. The table below summarizes what I weigh on real client projects.

CriterionOffset PaginationCursor Pagination
Random page accessSupported nativelyNot possible without workarounds
Performance at depthDegrades linearlyConstant time with proper indexes
Data consistencyProne to drift and duplicatesStable across concurrent writes
Total countCheap COUNT(*)Expensive; often estimated or omitted
ImplementationTrivial in any frameworkNeeds unique sort key and encoding
SEO / crawlabilityClean ?page=N URLsOpaque cursors less crawler-friendly
Best fitAdmin panels, search, small datasetsFeeds, timelines, infinite scroll

On a legal-tech portal, staff dashboards need offset so clerks jump to page 12 and see total case counts. The public activity feed uses cursors because users scroll continuously. Performance must stay flat as the archive grows. I've shipped similar splits on Mijar Law Associates and booking systems like Adventure Third Pole Trek. For teams evaluating architecture, the full-stack developer landscape in Nepal increasingly expects fluency in both patterns.

Hybrid designs are common. Expose offset on internal admin APIs. Expose cursor on mobile and public endpoints. Document the split in your OpenAPI spec so frontend teams do not assume one model everywhere. Pair pagination with API rate limiting and Laravel rate limiting to protect deep offset abuse.

How do you handle edge cases and common pitfalls in cursor pagination?

Cursor pagination has subtleties that surface only under load or multi-tenant auth. Address them during design, not after support tickets pile up.

Multi-column sorts and composite cursors

When sorting by multiple columns, the cursor must encode every sort column plus the unique tie-breaker. Laravel's cursorPaginate handles this when orderBy chains match the cursor decode path. Custom SQL needs compound WHERE clauses:

-- Composite cursor: status + created_at + id
SELECT * FROM cases
WHERE (status, created_at, id) > ('active', '2026-09-10 14:30:00', 58392)
ORDER BY status ASC, created_at DESC, id DESC
LIMIT 20;

MySQL 8.4 LTS and PostgreSQL 18 support row-value comparisons. Older MySQL versions need expanded OR conditions that are harder to optimize. Match your cursor logic to the database version in production, not just local dev. Use a JSON formatter when debugging encoded cursor payloads in API responses.

Cursor opacity, security, and multi-tenancy

Cursors should be opaque to clients. Do not expose raw IDs or timestamps in plain text. Laravel base64-encodes by default. That stops casual tampering but is not cryptographic. On SaaS platforms, validate decoded cursor values against authorization policies. I've audited portals where unsigned cursors let users enumerate records across tenant boundaries by incrementing encoded IDs. Sign cursors or re-check firm_id (or equivalent) on every request.

Deleted anchor records and expired cursors

If the row referenced by a cursor is deleted, the next request may return zero rows despite more data existing. Return a cursor_expired flag in meta so the client refreshes from the top. Alternatively, seek to the nearest valid position. Most mobile clients handle a soft reset gracefully when you document the contract in your API spec.

Pagination Decision TreeNeed random page access?YesNoUse offsetOver 100K rows?NoYesOffset OKFeed or scroll UI?NoYesHybrid approachUse cursorAlways: unique tie-breaker + opaque encoded cursors
Decision tree for cursor pagination vs offset based on navigation, dataset size, and UI pattern

How do you benchmark and validate pagination performance in production?

Complexity charts help, but your schema, indexes, and data skew decide real latency. Benchmark before you commit on high-traffic endpoints.

  1. Seed realistic volume. Use Laravel factories to reach 1M+ rows on cursor candidates. Uniform test data hides skew problems that appear in production.
  2. Profile at multiple depths. Test offset at pages 1, 100, 1,000, and 10,000. Test cursor at equivalent positions. Record p95 latency, not averages.
  3. Load-test under concurrency. Use k6 or wrk. Offset often looks fine in isolation but collapses when many clients scan deep pages at once.
  4. Confirm index usage. Run EXPLAIN ANALYZE on PostgreSQL or EXPLAIN FORMAT=JSON on MySQL. Cursor queries must index-seek. Missing composite indexes silently revert cursor performance toward offset-like scans. See Eloquent patterns for large datasets.
  5. Monitor after launch. Tag paginated queries in APM. Alert on p95 above your SLA. On legal-tech listings I target sub-200ms at any depth.

The hosting cost gap between a well-indexed cursor and a deep offset scan can run from Rs 500/month (~USD 4) to Rs 5,000/month (~USD 37) on busy endpoints. That difference matters for Nepal startups on tight budgets. PostgreSQL's LIMIT/OFFSET documentation explicitly notes that large offsets impose significant cost—worth citing when stakeholders ask why cursor pagination vs offset matters financially.

Hybrid Pagination ArchitectureAPI Gateway/api/v1/*Admin routes?page=N offsetPublic feeds?cursor= opaqueCOUNT + jumpStaff reportingIndex seekMobile infinite scrollShared DBComposite indexeson sort columnsRedis cache forCOUNT on adminMySQL 8.4 / PG 18
Production hybrid pattern: offset pagination vs cursor split across admin and public API routes on one database

Cache total counts on admin endpoints with Redis 8.10 if COUNT(*) becomes expensive. Public feeds rarely need totals. When they do, show approximate counts or "load more" without page numbers. For caching patterns, see caching strategies for high-traffic sites and REST API design best practices. Version your pagination contract alongside API versioning strategy so clients migrate cleanly.

Key Takeaways

  • Choose offset when users need random page access, total counts, or SEO-friendly ?page=N URLs on smaller datasets.
  • Choose cursor pagination for feeds, infinite scroll, and any list where depth must stay fast and stable under concurrent writes.
  • Always append a unique tie-breaker column (typically id) to every cursor sort chain.
  • Encode cursors opaquely and validate decoded values against tenant authorization on every request.
  • Run EXPLAIN at production scale before launch—missing composite indexes erase cursor gains.
  • Many production apps use both: offset on admin APIs, cursor on public mobile feeds from the same database.

People Also Ask

Is cursor pagination always faster than offset?

Not always on tiny tables. Offset on page 1 with a covering index can match cursor latency. Cursor wins at depth and under write churn. Once offset exceeds a few thousand rows or concurrent users hammer deep pages, cursor pagination vs offset stops being a close call.

Can you show total pages with cursor pagination?

Not cheaply. Exact totals require a full count scan. Most cursor APIs omit totals or return estimates. If your UI needs "Page 3 of 847," offset or a cached count endpoint is the practical choice.

Does Laravel support both pagination types?

Yes. Laravel 12 and 13 provide paginate() for offset and cursorPaginate() for cursor-based lists. Use the same model with different controller methods per route rather than forcing one style globally.

What happens if a user bookmarks a cursor URL?

Bookmarks work until the anchor row is deleted or sort order changes. Return a clear error or reset payload when the cursor is invalid. Document that behavior in your API so clients refresh gracefully instead of showing an empty list silently.

Choose Cursor Pagination vs Offset Before Launch

This guide covered the full cursor pagination vs offset decision: mechanics, Laravel code, edge cases, benchmarks, and hybrid architecture. Neither approach is universally better. Offset serves admin panels and search. Cursor serves feeds and mobile scroll at scale. The expensive mistake is defaulting to offset everywhere because it is familiar, then refactoring under traffic.

Decide during API design. Document the rationale. Benchmark with realistic row counts. If you are building a Laravel API and need help with pagination strategy, database indexes, or production deployment, contact us to discuss your project or explore custom software development. Competent Laravel developers in Nepal treat pagination as architecture—not an afterthought.

Frequently Asked Questions

Offset uses numeric page numbers skipping records, while cursor uses an opaque pointer to a specific record position for consistent sequential fetching without gaps or duplicates.

Use offset when users need random page access, total counts, or simple admin tables where dataset stability matters less than navigation flexibility and implementation simplicity.

Offset degrades linearly; page 1000 at 50 items scans 50,000 rows. Cursors remain constant time O(1) regardless of depth because they seek directly via indexed lookups.

Because offset calculates position from the start each request. If rows are inserted or deleted between requests, the absolute position shifts, causing skipped or repeated items in subsequent pages. Cursor pagination avoids this by anchoring to a specific record value rather than a row number, making it stable for real-time feeds, notifications, or high-write tables common in Laravel applications handling bookings or orders.

Use Eloquent's cursorPaginate method introduced in Laravel 8 and refined through version 12. It automatically generates opaque cursors based on your orderBy column. Ensure you order by a unique, indexed column like id or created_at. The response includes next_cursor and previous_cursor metadata. This works natively with API Resources and requires no extra packages, though spatie/laravel-cursor-pagination offers additional customization if needed for complex multi-column sorting.

Yes, but the cursor column must exist in the result set and be uniquely identifiable across joined tables. Prefix columns explicitly to avoid ambiguity. Composite cursors using multiple columns require manual encoding. In my experience building directory sites like Lawyers Pokhara, joining users with profiles required careful index design on the foreign key plus timestamp combination to maintain cursor performance without full table scans during filtering.

Create composite indexes matching your ORDER BY clause exactly. For cursorPaginate('created_at', 'id'), add INDEX idx_created_id (created_at, id). Without this, MySQL performs filesort operations defeating cursor benefits. On production legal-tech portals I maintain, adding proper composite indexes reduced p95 latency from 800ms to under 50ms for paginated document listings exceeding 100,000 records. Always verify with EXPLAIN ANALYZE before deploying.

Append a unique tiebreaker column like primary key to your ordering. Cursor pagination requires deterministic ordering to generate stable pointers. Sorting only by created_at fails when multiple records share identical timestamps. Configure cursorPaginate(['created_at', 'id']) to ensure uniqueness. This pattern is essential for chronological feeds in eCommerce order histories or booking systems where batch imports create timestamp collisions.

No. Cursors are sequential pointers lacking positional context. You cannot compute page 47 directly. Implement hybrid approaches offering both modes: offset for admin dashboards requiring jump navigation, cursors for infinite-scroll consumer interfaces. On client projects like Ajako Deal, we exposed separate endpoints allowing vendors to browse listings via offset while buyers received cursor-paginated deal feeds optimized for mobile scrolling performance.

Return cursors as opaque strings in response metadata alongside data arrays. Never expose internal IDs or timestamps directly. Structure follows JSON:API or custom conventions with next_cursor, prev_cursor, and has_more boolean fields. In Laravel API Resources, wrap collections using CursorPaginator resource classes. Clients pass these values verbatim as query parameters. This abstraction allows backend implementation changes without breaking API contracts consumed by Vue frontends or mobile apps.

Exposed cursors can leak sortable column values if poorly encoded. Always encrypt or hash cursor payloads using Laravel's Crypt facade. Validate cursor structure server-side to prevent injection attacks. Rate-limit cursor endpoints since sequential traversal enables efficient scraping. On public-facing directories I build, signed cursors prevent attackers from manipulating pointers to enumerate private records or bypass intended access boundaries defined by policy scopes.

Cache individual cursor responses keyed by cursor value plus filter parameters. Avoid caching entire result sets since cursors represent positions not content. Invalidate caches on write operations affecting sorted columns. For read-heavy endpoints on platforms like Nepal Gift Card, we cache cursor pages with 60-second TTLs, reducing database load by 80 percent during peak traffic while maintaining acceptable freshness for digital product listings that change infrequently.

Yes, but relevance scores are non-deterministic tiebreakers. Order primarily by MATCH score descending, then by id ascending for stable cursors. Store computed relevance in generated columns if possible. Full-text searches already carry performance costs; ensure covering indexes exist. In practice on legal information sites, we precompute search rankings into materialized views updated via scheduled jobs, allowing cursor pagination over static ranked results instead of expensive runtime scoring.

Version your API or add cursor parameters alongside existing offset/page params. Deprecate offset gradually while monitoring client adoption. Provide migration guides explaining cursor semantics differ fundamentally. Maintain dual support during transition periods. On long-running WooCommerce integrations, we introduced cursor endpoints as v2 while keeping v1 offset functional for six months, allowing third-party developers adequate time to update their consumption patterns without breaking production systems.

Write integration tests inserting records between paginated requests to verify stability. Assert cursor opacity prevents client-side decoding. Test edge cases: empty result sets, single-record pages, boundary conditions at dataset start/end. Verify ordering consistency across concurrent writes. In Laravel test suites, use RefreshDatabase with factory sequences creating predictable timestamp distributions. Production debugging often reveals issues invisible in sterile test environments, so include logging of cursor generation parameters for traceability.

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: