Pagination — LIMIT/OFFSET vs cursor-based
Advanced5 min read·fw-07-006
sqlperformance
Concept
Pagination divides large result sets into pages. The two main strategies are offset-based (LIMIT/OFFSET) and cursor-based. Each has different performance characteristics and use cases.
Offset pagination (LIMIT 20 OFFSET 40):
- Easy to implement. Supports random page access (
page=5,page=10). - Performance degrades on high offsets — the DB must scan all preceding rows even if it only returns 20.
OFFSET 10000is slow. - Records can shift between requests (new inserts push rows to the next page).
- Returns total count (for "Page 2 of 15" UI). Total count query:
SELECT COUNT(*) FROM ...with same WHERE clause but no LIMIT.
Cursor pagination:
- Uses
WHERE id > ?instead ofOFFSET. The DB uses the index — always fast. - No total count — no "Page 2 of 15" UI.
- Only supports "next" and "previous" — no random page access.
- Stable — new inserts don't shift existing records.
- Best for infinite scroll, real-time feeds.
Paginator object: Wraps the results + pagination metadata:
- Total count (offset only), current page, per-page, total pages.
previousPageUrl(),nextPageUrl().links()for HTML page link generation.
hasMorePages(): Returns true if there's a next page. For cursor pagination, fetch perPage + 1 rows — if you get 21 rows when asking for 20, there's a next page.
Code Example
php
<?php
namespace Framework\Database;
class Paginator
{
public function __construct(
private readonly array $items,
private readonly int $total,
private readonly int $perPage,
private readonly int $currentPage,
) {}
public function items(): array { return $this->items; }
public function total(): int { return $this->total; }
public function perPage(): int { return $this->perPage; }
public function currentPage(): int { return $this->currentPage; }
public function lastPage(): int { return (int) ceil($this->total / $this->perPage); }
public function hasMorePages(): bool { return $this->currentPage < $this->lastPage(); }
public function nextPage(): ?int { return $this->hasMorePages() ? $this->currentPage + 1 : null; }
public function previousPage(): ?int { return $this->currentPage > 1 ? $this->currentPage - 1 : null; }
public function firstItem(): int { return ($this->currentPage - 1) * $this->perPage + 1; }
public function lastItem(): int { return min($this->firstItem() + count($this->items) - 1, $this->total); }
public function toArray(): array
{
return [
'data' => $this->items,
'current_page' => $this->currentPage,
'per_page' => $this->perPage,
'total' => $this->total,
'last_page' => $this->lastPage(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
'has_more' => $this->hasMorePages(),
];
}
}
class QueryBuilder
{
public function paginate(int $perPage = 15, int $currentPage = 1): Paginator
{
// Count query
$countBuilder = clone $this;
$total = (int) $countBuilder->count();
// Data query
$offset = ($currentPage - 1) * $perPage;
$items = $this->limit($perPage)->offset($offset)->get();
return new Paginator($items, $total, $perPage, $currentPage);
}
public function cursorPaginate(int $perPage = 15, ?int $afterId = null): array
{
$query = $this->limit($perPage + 1); // fetch one extra to detect next page
if ($afterId !== null) {
$query->where('id', '>', $afterId);
}
$items = $query->orderBy('id')->get();
$hasMore = count($items) > $perPage;
$data = $hasMore ? array_slice($items, 0, $perPage) : $items;
$nextId = $hasMore ? end($data)['id'] : null;
return ['data' => $data, 'next_cursor' => $nextId, 'has_more' => $hasMore];
}
public function limit(int $limit): static { $this->limit = $limit; return $this; }
public function offset(int $offset): static { $this->offset = $offset; return $this; }
}