Choosing a Livewire Pagination Strategy for Large Datasets
Compare paginate(), simplePaginate(), and cursorPaginate() in Laravel Livewire, including database load, URL behavior, and UI constraints for each approach.
01 Jan 2026, 05:02 UTC

The decision: matching pagination method to dataset size and UI
When a Laravel Livewire component renders a list that grows beyond a few hundred rows, the pagination method you choose stops being a cosmetic detail. The default paginate() call issues a separate COUNT query and uses SQL OFFSET to skip rows. On a large table, both operations become expensive: the count query scans an index or table, and deep offsets force the database to read and discard rows before returning the page you want. Livewire's WithPagination trait keeps the page state in the URL and updates the DOM partially, but it does not change the underlying SQL cost.
The practical decision is therefore constrained by three questions: How many rows are in the table? Does the UI need a total page count or jump-to-page links? And is the database indexed for the ordering column? The answers usually point to one of three supported strategies.
Comparing the supported options
Livewire components use the WithPagination trait, which exposes a $page public property and synchronizes it with the ?page= query string. The underlying Eloquent method you call on the query builder determines database behavior and which pagination view features are available.
| Method | Count query | Deep-offset cost | Jump to page N | Typical use case |
|---|---|---|---|---|
paginate() | Yes | High | Yes | Small-to-medium tables needing full page controls |
simplePaginate() | No | High | No | Large tables where only previous/next links are acceptable |
cursorPaginate() | No | Low | No | Very large tables with sequential or infinite-scroll UX |
paginate(): full controls, higher database load
The standard paginate() method runs a second query to calculate the total record count. Livewire's default pagination views use that total to render numbered links and a "last page" button. The trade-off is that COUNT(*) on an unindexed or very large table can dominate response time. Additionally, OFFSET performance degrades as the offset value grows: requesting page 500 makes the database read and discard all preceding rows.
simplePaginate(): fewer queries, fewer controls
simplePaginate() removes the count query entirely. The paginator only knows whether a next or previous page exists, so the rendered links are limited to Previous and Next. This reduces database load, but the SQL still uses OFFSET, so deep pages remain slow. It is a good fit when the UI does not need to display "Page 3 of 50" and users are unlikely to jump deep into the results.
cursorPaginate(): stable performance, restricted navigation
cursorPaginate() replaces OFFSET with a WHERE id > ? clause ordered by the cursor column, usually the primary key. Because the database seeks directly to the last seen row, performance stays consistent regardless of how many pages the user has already viewed. The limitation is structural: without a total count, the paginator cannot render numbered links, and it does not support arbitrary page numbers. A user cannot jump to page 10; they can only move forward or backward from the current cursor.
Concrete implementation: switching to cursorPaginate()
If your dataset is large and the UX can live with sequential navigation, cursorPaginate() is often the safest default. The following component assumes a posts table with an auto-incrementing id. Run the Artisan command from your project root to scaffold the component; it requires write permissions to app/Livewire and resources/views/livewire.
php artisan make:livewire PostList
In the generated class, add the trait and replace the standard pagination call:
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Post;
class PostList extends Component
{
use WithPagination;
public function render()
{
return view('livewire.post-list', [
'posts' => Post::orderBy('id')->cursorPaginate(15),
]);
}
}
In the Blade view, render the cursor links. Because there is no total count, avoid calling $posts->total() or $posts->lastPage(); those methods do not exist on a cursor paginator.
<div>
@foreach ($posts as $post)
<div>{{ $post->title }}</div>
@endforeach
{{ $posts->links() }}
</div>
Note that WithPagination still syncs a page-like parameter to the URL so that browser refresh and back-button behavior remain predictable. Changing the public $page property directly—through wire:model or programmatic assignment—can trigger unexpected re-renders, so treat it as trait-managed state.
Validating the choice before committing
Before deploying a pagination change, verify the actual database behavior rather than assuming it. Start by confirming the installed Livewire version, because WithPagination internals and view names have changed across major releases. Run this from your project root:
composer show livewire/livewire
Expected check: the output lists the installed version. Compare it against the pagination documentation packaged with that release.
Next, compare query counts. In an interactive Tinker shell (php artisan tinker from the project root), enable the query log and execute both pagination methods against a production-scale table:
DB::enableQueryLog();
Post::orderBy('id')->paginate(15);
$paginateQueries = DB::getQueryLog();
DB::flushQueryLog();
Post::orderBy('id')->cursorPaginate(15);
$cursorQueries = DB::getQueryLog();
count($paginateQueries); // includes the COUNT query
count($cursorQueries); // typically one SELECT
Expected check: paginate() produces at least two queries (the data select plus the count), while cursorPaginate() produces one. On a table with millions of rows, also measure execution time for a deep page, such as offset 100000, to confirm the degradation.
Finally, inspect the rendered HTML. For paginate() and simplePaginate(), confirm that links contain ?page=2 and that clicking them updates only the Livewire DOM diff rather than triggering a full browser navigation. For cursorPaginate(), verify that the generated links carry the cursor parameter and that no numbered page links appear.
Limitations and version sensitivity
- cursorPaginate() cannot jump to page N. If your product requires "Go to page 5" or a total result count, this method is unsuitable.
- paginate() with OFFSET degrades at scale. Test with production-scale data before choosing it; an unindexed ordering column makes the problem worse.
- WithPagination behavior differs between Livewire major versions. Property names, view paths, and URL parameter handling may vary. Verify against your installed release rather than relying on documentation for a different version.
To check the result after implementation, load the component in a browser, navigate through several pages, and confirm that the URL updates and the list content changes without a full page reload. If the list appears frozen or the browser performs a hard navigation, the wire:navigate or pagination view configuration may need adjustment for your Livewire version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.