Tinkerwell - The PHP Scratchpad

Handling Large Datasets with Pagination and Cursors in Laravel MongoDB

Last updated on by

Handling Large Datasets with Pagination and Cursors in Laravel MongoDB image

Modern applications routinely deal with datasets containing millions of records. Whether you're building an e-commerce platform with extensive product catalogs, a social media feed, or an analytics dashboard, you'll eventually face the question of how to display large amounts of data without overwhelming your server or your users. Pagination is the standard solution, but not all pagination methods perform equally as your data grows.

This article explores two approaches to pagination when working with Laravel and MongoDB: offset-based pagination using skip() and limit(), and cursor-based pagination that uses document pointers. You'll learn how each method works internally, why offset pagination degrades at scale, and when cursor-based pagination offers a better alternative. By the end, you'll have practical implementation examples and clear guidance on choosing the right approach for your application.

Offset Pagination: Mechanics and Performance Problems

Offset pagination is the traditional approach most developers learn first. The concept is straightforward: you tell the database to skip a certain number of records and return the next batch. If you want page 5 with 20 items per page, you skip the first 80 records and fetch the next 20.

How Offset Pagination Works

In MongoDB, offset pagination uses skip() to set the starting point and limit() to control how many documents to return. Laravel's Eloquent provides the paginate() method that handles this automatically, or you can use skip() and take() manually for more control.

Here's a basic implementation:

use App\Models\Product;
 
class ProductController extends Controller
{
public function index(Request $request)
{
$page = max((int) $request->input('page', 1), 1);
$perPage = 20;
$skip = ($page - 1) * $perPage;
 
$products = Product::orderBy('created_at', 'desc')
->skip($skip)
->take($perPage)
->get();
 
return response()->json($products);
}
}

The skip value calculation is simple: ($page - 1) * $perPage. For page 1, you skip 0 records. For page 2, you skip 20. For page 100, you skip 1,980.

Laravel's built-in paginate() method wraps this logic and adds metadata like total pages and navigation links:

$products = Product::orderBy('created_at', 'desc')->paginate(20);

This returns a paginator object with the results plus information about total records, current page, and URLs for previous and next pages.

Why Offset Pagination Fails at Scale

The problem with skip() becomes apparent when you examine what MongoDB does to execute the query. When you call skip(1000000), MongoDB doesn't jump directly to record 1,000,001. It must scan through all one million documents before returning your results. The database reads and discards every skipped document, which means page 10,000 takes dramatically longer than page 1.

Query time grows linearly with the offset value. If page 1 takes 5 milliseconds, page 1,000 might take 500 milliseconds, and page 10,000 could take several seconds. This degradation happens regardless of how well you've indexed your collection, because the skip operation itself requires traversing documents.

You can observe this behavior using MongoDB's explain feature:

db.products.find().sort({ created_at: -1 }).skip(1000000).limit(20).explain("executionStats")

The docsExamined field in the output will show over one million documents examined, even though you're only returning 20.

The Count Problem

Offset pagination usually displays "Page X of Y" in the interface, which requires knowing the total number of documents in the collection. Getting this count on large collections is expensive. MongoDB must scan the entire collection to return an accurate count, and this operation doesn't benefit from indexes the way filtered queries do.

Several strategies can help mitigate the cost:

  • Cache the count: store the total count in a separate location and refresh it periodically rather than calculating it on every request
  • Use estimated counts: MongoDB's estimatedDocumentCount() returns an approximate count much faster than an exact count
  • Avoid displaying totals: show "Next" and "Previous" buttons without revealing the total number of pages
// Fast estimated count
$estimatedCount = DB::connection('mongodb')
->collection('products')
->raw(function ($collection) {
return $collection->estimatedDocumentCount();
});

When Offset Pagination Still Makes Sense

Despite its limitations, offset pagination works well in certain situations:

  • Small to medium datasets: collections under 100,000 records rarely show noticeable performance issues
  • Admin panels: internal tools where convenience outweighs performance concerns, and datasets are often filtered to manageable sizes
  • Arbitrary page access: when users need to jump directly to page 50 or page 200, offset pagination is the only practical option

If your application fits these criteria, offset pagination's simplicity makes it the right choice. The performance problems only arise when users navigate to deep pages in large collections.

Cursor Pagination: The Scalable Alternative

Cursor-based pagination takes a different approach. Instead of counting how many records to skip, you use a pointer to the last record you saw and ask for everything that comes after it. This technique is also known as keyset pagination or seek pagination.

What is Cursor-Based Pagination

The cursor is a value that uniquely identifies a position in your sorted result set. When you request the next page, you pass this cursor, and the database queries for documents where the sort field is greater than (or less than, depending on direction) the cursor value.

Consider a timeline of posts sorted by creation date. Instead of saying "skip the first 100 posts," you say "give me posts created after this timestamp." The database can use an index to jump directly to that position without scanning earlier documents.

How Cursor Pagination Works

The flow typically follows this pattern:

  1. First request: fetch the first N records and note the cursor value of the last record
  2. Subsequent requests: fetch N records where the sort field is greater than the cursor value
  3. Repeat: each response includes a new cursor for the next page

The cursor is typically the _id field, a timestamp like created_at, or a compound value when sorting by non-unique fields.

// First page - no cursor needed
$products = Product::orderBy('_id', 'asc')
->limit(20)
->get();
 
$lastId = $products->last()->_id;
 
// Second page - use the cursor
$products = Product::where('_id', '>', $lastId)
->orderBy('_id', 'asc')
->limit(20)
->get();

Because _id is always indexed in MongoDB, this query executes with consistent performance regardless of how deep into the dataset you've navigated.

Why Cursors are Efficient

The key difference is how the database executes the query. With a cursor condition like where('_id', '>', $lastId), MongoDB uses the index to jump directly to the starting point. There's no scanning or discarding of documents. The query examines only the documents it returns.

This gives cursor pagination O(1) time complexity for any "page." Whether you're fetching the equivalent of page 1 or page 10,000, the query takes the same amount of time. The only factor affecting performance is the number of documents returned, not the position in the dataset.

The Trade-offs

Cursor pagination has limitations that affect user interface design:

  • No arbitrary page jumping: users can only move forward or backward sequentially, not jump to page 50 directly
  • No "Page X of Y" display: without a skip-based system, there's no concept of numbered pages
  • Cursor management: clients must track the cursor between requests

These constraints make cursor pagination unsuitable for interfaces that require traditional page numbers. However, many modern applications don't need numbered pages at all. Infinite scroll interfaces, "Load more" buttons, and API pagination for mobile apps all work naturally with cursor-based approaches.

Ideal Use Cases

Cursor pagination excels in these scenarios:

  • Infinite scroll: social media feeds, news sites, and content discovery interfaces where users scroll continuously
  • Mobile and single-page applications: APIs that serve data to frontends with "Load more" functionality
  • Real-time feeds: activity logs, notifications, and event streams where new content arrives frequently
  • Large-scale APIs: public APIs where preventing deep pagination attacks is important

Implementation with Laravel MongoDB

Laravel provides built-in support for cursor pagination through the cursorPaginate() method, which works with the Laravel MongoDB package. This section covers both the built-in method and custom implementations for more complex requirements.

Laravel's cursorPaginate Method

Available since Laravel 8, cursorPaginate() handles cursor encoding, decoding, and navigation link generation automatically:

use App\Models\Product;
 
class ProductController extends Controller
{
public function index()
{
$products = Product::orderBy('_id')->cursorPaginate(15);
 
return response()->json($products);
}
}

The response includes cursor metadata that clients can use for navigation:

{
"data": [...],
"path": "http://example.com/products",
"per_page": 15,
"next_cursor": "eyJfaWQiOiI2NTBhYjEyMzQ1NiIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0",
"next_page_url": "http://example.com/products?cursor=eyJfaWQiOiI2NTBhYjEyMzQ1NiIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0",
"prev_cursor": null,
"prev_page_url": null
}

The cursor is a base64-encoded JSON object containing the last document's sort field values. Laravel automatically decodes this on subsequent requests and builds the appropriate query.

For API responses, you might format the output differently:

public function index(Request $request)
{
$products = Product::orderBy('created_at', 'desc')
->orderBy('_id', 'desc')
->cursorPaginate(20);
 
return response()->json([
'products' => $products->items(),
'meta' => [
'next_cursor' => $products->nextCursor()?->encode(),
'prev_cursor' => $products->previousCursor()?->encode(),
'has_more' => $products->hasMorePages(),
],
]);
}

Custom Cursor Pagination

In some cases you need more control than cursorPaginate() provides. Perhaps you're working with complex sorting, need a specific cursor format for your frontend, or want to integrate with existing API conventions.

Here's a basic custom implementation:

use App\Models\Order;
use Illuminate\Http\Request;
 
class OrderController extends Controller
{
public function index(Request $request)
{
$perPage = 20;
$cursor = $request->input('cursor');
 
$query = Order::orderBy('_id', 'asc');
 
if ($cursor) {
$decodedCursor = base64_decode($cursor);
$query->where('_id', '>', $decodedCursor);
}
 
// Fetch one extra to check if more pages exist
// then remove it before returning
$orders = $query->limit($perPage + 1)->get();
 
$hasMore = $orders->count() > $perPage;
if ($hasMore) {
$orders->pop();
}
 
$nextCursor = $hasMore
? base64_encode($orders->last()->_id)
: null;
 
return response()->json([
'orders' => $orders,
'next_cursor' => $nextCursor,
'has_more' => $hasMore,
]);
}
}

The trick of fetching one extra document ($perPage + 1) lets you determine whether more pages exist without running a separate count query.

Handling Compound Sort Orders

When sorting by non-unique fields like created_at, multiple documents might share the same value. This creates ambiguity about which document should come next. The solution is to add a unique field as a tie-breaker, typically _id.

public function index(Request $request)
{
$perPage = 20;
$cursor = $request->input('cursor');
 
$query = Order::orderBy('created_at', 'desc')
->orderBy('_id', 'desc');
 
if ($cursor) {
$decoded = json_decode(base64_decode($cursor), true);
 
$query->where(function ($q) use ($decoded) {
$q->where('created_at', '<', $decoded['created_at'])
->orWhere(function ($q2) use ($decoded) {
$q2->where('created_at', '=', $decoded['created_at'])
->where('_id', '<', $decoded['_id']);
});
});
}
 
$orders = $query->limit($perPage + 1)->get();
 
$hasMore = $orders->count() > $perPage;
if ($hasMore) {
$orders->pop();
}
 
$nextCursor = null;
if ($hasMore && $orders->isNotEmpty()) {
$lastOrder = $orders->last();
$nextCursor = base64_encode(json_encode([
'created_at' => $lastOrder->created_at->toISOString(),
'_id' => (string) $lastOrder->_id,
]));
}
 
return response()->json([
'orders' => $orders,
'next_cursor' => $nextCursor,
'has_more' => $hasMore,
]);
}

The compound cursor condition says: "Give me documents where created_at is less than the cursor's timestamp, OR where created_at equals the cursor's timestamp AND _id is less than the cursor's ID." This ensures consistent ordering even when timestamps collide.

Choosing the Right Approach

The decision between offset and cursor pagination depends on your dataset size, user interface requirements, and performance constraints.

Comparison Overview

Factor Offset pagination Cursor pagination
Performance at scale Degrades with offset size Consistent regardless of position
Implementation complexity Simple Moderate
Page jumping Supported Not supported
"Page X of Y" display Supported Not supported
Suitable dataset size Under 100K records Any size

Choose Offset Pagination when

  • Your dataset is small to medium (under 100,000 records)
  • Users need to jump to specific pages directly
  • You need to display "Page 3 of 47" in the interface
  • You're building admin panels where convenience matters more than performance
  • The filtered result set is always manageable, even if the total collection is large

Choose Cursor Pagination when

  • Your dataset is large or growing without bounds
  • You're building infinite scroll or "Load more" interfaces
  • You're creating APIs for mobile applications or single-page apps
  • Performance consistency matters more than random page access
  • You want to prevent deep pagination attacks on public APIs

Hybrid Approaches

Some applications benefit from combining both methods:

  • Offset for early pages, cursor for deep navigation: allow numbered pages for the first 10-20 pages, then switch to cursor-based "Load more" for users who want to go deeper
  • Cached counts with cursor pagination: use cursor pagination for data fetching but maintain a cached total count that refreshes periodically, giving users some sense of the dataset size without the performance hit

Performance Tips

Beyond choosing the right pagination strategy, several optimizations can improve performance for both approaches.

Index Your Cursor Fields

Cursor pagination only works efficiently if the fields you're using for cursors are indexed. For queries that sort by created_at and _id, create a compound index:

use Illuminate\Support\Facades\Schema;
use MongoDB\Laravel\Schema\Blueprint;
 
Schema::create('orders', function (Blueprint $collection) {
$collection->index(['created_at' => -1, '_id' => -1]);
});

MongoDB automatically creates an ascending index on _id for every collection. If you're sorting only by _id in ascending order, the built-in index handles it. But for compound sorts like created_at descending then _id descending, you need the explicit compound index shown above. MongoDB can't combine separate single-field indexes efficiently for this query pattern.

Without this index, MongoDB falls back to scanning documents even with cursor pagination. The index order matters: it should match your sort order exactly. If you sort by created_at descending and then _id descending, your index should use the same directions.

For filtered queries that also paginate, consider compound indexes that include filter fields first. If you frequently query orders by status and paginate by date, an index on ['status' => 1, 'created_at' => -1, '_id' => -1] lets MongoDB satisfy both the filter and the cursor condition efficiently.

Use Projections

Fetch only the fields your application needs. Large documents with nested arrays or embedded objects take longer to transfer and consume more memory:

$products = Product::select(['name', 'price', 'category', 'created_at'])
->orderBy('_id')
->cursorPaginate(20);

This reduces network overhead and speeds up serialization on both the database and application sides. The difference becomes significant when documents contain large text fields, arrays with many elements, or deeply nested structures. A document that's 50KB when fully loaded might only be 2KB when you select the fields you actually display.

Cache Strategically

For frequently accessed first pages, consider caching:

$firstPage = Cache::remember('products:first_page', now()->addMinutes(5), function () {
return Product::orderBy('created_at', 'desc')
->limit(20)
->get();
});

First pages often receive the most traffic, so caching them reduces database load significantly. Deeper pages are accessed less frequently and can be fetched fresh.

If you display total counts, cache them separately with an appropriate TTL rather than counting on every request:

$totalProducts = Cache::remember('products:count', now()->addHours(1), function () {
return Product::count();
});

Consider invalidating cached first pages when new documents are inserted, since stale first pages are more noticeable to users than stale deep pages. You can use cache tags or event listeners to handle this:

// In your model or observer
protected static function booted()
{
static::created(function () {
Cache::forget('products:first_page');
});
}

Conclusion

Pagination is a fundamental pattern for handling large datasets, but the implementation you choose has real consequences for performance and user experience. Offset pagination with skip() and limit() is simple to implement and works well for smaller datasets or admin interfaces where arbitrary page access matters. However, its performance degrades linearly as users navigate deeper into large collections.

Cursor pagination maintains consistent performance regardless of position by using indexed lookups instead of document scanning. Laravel MongoDB supports both approaches out of the box, with paginate() for offset-based pagination and cursorPaginate() for cursor-based pagination.

When deciding between them, consider your dataset size and growth trajectory, whether users need to jump to arbitrary pages, and how critical performance consistency is for your use case. For large-scale applications, cursor pagination is usually the better choice. For smaller datasets with traditional pagination UI requirements, offset pagination remains practical.

Whatever approach you choose, remember to index your sort fields, use projections to minimize data transfer, and benchmark with production-scale data before committing to a strategy. Performance characteristics that seem acceptable with 10,000 records often look different with 10 million. If you're building a new feature that handles large or growing datasets, start with cursorPaginate() and save yourself the migration headache later.

Farhan Hasin Chowdhury photo

Backend engineer and technical author teaching millions through code and content.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Code Review

Get expert guidance in a few days with a Laravel code review

Visit Laravel Code Review
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $3200/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
PhpStorm logo

PhpStorm

The go-to PHP IDE with extensive out-of-the-box support for Laravel and its ecosystem.

PhpStorm
Laravel Cloud logo

Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Cloud
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit

The latest

View all →
Handling Large Datasets with Pagination and Cursors in Laravel MongoDB image

Handling Large Datasets with Pagination and Cursors in Laravel MongoDB

Read article
Driver-Based Architecture in Spatie's Laravel PDF v2 image

Driver-Based Architecture in Spatie's Laravel PDF v2

Read article
Why Your Livewire Dashboard Jumps (And How to Fix It) - Laravel In Practice EP18 image

Why Your Livewire Dashboard Jumps (And How to Fix It) - Laravel In Practice EP18

Read article
Laravel Live UK returns to London on June 18-19, 2026 image

Laravel Live UK returns to London on June 18-19, 2026

Read article
Laravel Related Content: Semantic Relationships Using pgvector image

Laravel Related Content: Semantic Relationships Using pgvector

Read article
Filament v5.2.0 Adds a Callout Component image

Filament v5.2.0 Adds a Callout Component

Read article