Aplikasi web modern berbasis Laravel dan Inertia.js sering menggunakan pagination standar (LengthAwarePaginator). Masalah muncul saat volume data menembus jutaan baris dan pengguna menavigasi ke halaman dalam (deep offset). Query bawaan memaksa database memuat seluruh kolom ke memory sebelum membuang sebagian besar baris tersebut.

Akar Masalah: Buffer Pool Bloat pada Deep Offset

Query offset pagination standar dieksekusi dengan struktur berikut:

SELECT * FROM orders ORDER BY created_at DESC LIMIT 15 OFFSET 500000;

Database tidak langsung melompat ke baris ke-500.001. Mesin database harus membaca 500.015 baris data dari disk atau buffer pool, mengurutkannya, lalu membuang 500.000 baris pertama hanya untuk mengambil 15 baris sisanya.

Jika tabel memiliki puluhan kolom atau tipe data besar seperti TEXT atau JSON, ukuran I/O melonjak drastis. Seluruh halaman data harus dibaca ke memori (buffer pool), mendepak data penting lain dari cache dan memicu bottleneck CPU serta storage.

Pola Solusi: Deferred Join (Late Row Lookup)

Deferred join memisahkan pagination menjadi dua tahap:

  1. Index-Only Scan: Subquery hanya mengambil kolom Primary Key (id) menggunakan index penutup (covering index) yang berukuran ringkas di memori.
  2. Late Row Lookup: Hasil 15 id tersebut di-JOIN kembali ke tabel utama untuk mengambil kolom lengkap hanya untuk 15 baris yang akan dirender.

Query deferred join:

SELECT orders.* 
FROM orders
INNER JOIN (
    SELECT id FROM orders 
    ORDER BY created_at DESC 
    LIMIT 15 OFFSET 500000
) AS batch ON orders.id = batch.id
ORDER BY orders.created_at DESC;

Subquery beroperasi sepenuhnya di index cache. Pembacaan tabel utama hanya terjadi untuk 15 baris akhir.

Perbandingan EXPLAIN ANALYZE

Berikut perbandingan rencana eksekusi pada tabel PostgreSQL berisi 2.000.000 baris.

1. Query Konvensional

EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM orders ORDER BY created_at DESC LIMIT 15 OFFSET 500000;

-- Execution Time: 487.312 ms
-- Buffers: shared hit=42100 read=18240

Query membaca puluhan ribu blok halaman buffer karena harus mengakses seluruh data baris dari disk/buffer pool.

2. Query Deferred Join

EXPLAIN (ANALYZE, BUFFERS) 
SELECT orders.* FROM orders
INNER JOIN (
    SELECT id FROM orders ORDER BY created_at DESC LIMIT 15 OFFSET 500000
) AS batch ON orders.id = batch.id
ORDER BY orders.created_at DESC;

-- Execution Time: 18.420 ms
-- Buffers: shared hit=1420 read=0

Latensi turun drastis. I/O berkurang karena pemindaian offset dilakukan murni pada B-Tree index.

Implementasi Macro Eloquent Reusable

Daftarkan macro pada app/Providers/AppServiceProvider.php agar dapat digunakan secara global pada model Eloquent.

namespace App\Providers;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Builder::macro('deferredPaginate', function (int $perPage = 15, array $columns = ['*'], string $pageName = 'page', ?int $page = null) {
            /** @var Builder $this */
            $page = $page ?: LengthAwarePaginator::resolveCurrentPage($pageName);
            $total = (clone $this)->toBase()->getCountForPagination();

            if ($total === 0) {
                return new LengthAwarePaginator([], 0, $perPage, $page, [
                    'path' => LengthAwarePaginator::resolveCurrentPath(),
                    'pageName' => $pageName,
                ]);
            }

            $primaryKey = $this->model->getQualifiedKeyName();
            $keyName = $this->model->getKeyName();

            // Subquery ambil ID via index
            $ids = (clone $this)
                ->select($primaryKey)
                ->forPage($page, $perPage)
                ->pluck($keyName);

            // Late row lookup
            $items = $this->model->newQuery()
                ->whereIn($primaryKey, $ids)
                ->with($this->getEagerLoads())
                ->orderBy($this->model->getQualifiedKeyName(), 'desc')
                ->get($columns);

            return new LengthAwarePaginator($items, $total, $perPage, $page, [
                'path' => LengthAwarePaginator::resolveCurrentPath(),
                'pageName' => $pageName,
            ]);
        });
    }
}
skipped: dynamic secondary order preservation inside macro, add when complex multi-column sorting is required.

Integrasi Inertia Tanpa Breaking Changes

Di controller, ganti paginate() dengan deferredPaginate():

namespace App\Http\Controllers;

use App\Models\Order;
use Inertia\Inertia;
use Inertia\Response;

class OrderController extends Controller
{
    public function index(): Response
    {
        $orders = Order::query()
            ->select(['id', 'order_number', 'customer_id', 'total_amount', 'status', 'created_at'])
            ->with('customer:id,name')
            ->orderBy('created_at', 'desc')
            ->deferredPaginate(15);

        return Inertia::render('Orders/Index', [
            'orders' => $orders,
        ]);
    }
}

Karena macro mengembalikan instance LengthAwarePaginator, struktur JSON yang diterima komponen Vue atau React tetap identik:

{
  "data": [...],
  "current_page": 33334,
  "last_page": 66667,
  "per_page": 15,
  "total": 1000000,
  "links": [...]
}

Komponen frontend tidak memerlukan refactoring sama sekali:

<script setup>
import { Link } from '@inertiajs/vue3';

defineProps({
    orders: Object
});
</script>

<template>
    <div>
        <table>
            <tr v-for="order in orders.data" :key="order.id">
                <td>{{ order.order_number }}</td>
                <td>{{ order.total_amount }}</td>
            </tr>
        </table>

        <div class="pagination">
            <Link 
                v-for="(link, index) in orders.links" 
                :key="index" 
                :href="link.url || '#'" 
                v-html="link.label" 
                :class="{ 'active': link.active }" 
            />
        </div>
    </div>
</template>

Batasan dan Alternatif

Pertimbangkan aspek berikut sebelum menerapkan deferred join:

  • Index Requirement: Kolom yang digunakan pada klausul WHERE dan ORDER BY wajib memiliki composite index yang mencakup primary key. Tanpa index penutup, database tetap melakukan table scan.
  • Halaman Pertama (Page 1): Deferred join memiliki overhead marginal (eksekusi subquery tambahan) dibanding query standar pada offset 0. Keuntungannya baru terlihat nyata saat offset melewati ribuan baris.
  • Cursor Pagination: Jika aplikasi tidak membutuhkan navigasi lompat halaman acak (misalnya infinite scroll), gunakan cursorPaginate() bawaan Laravel. Kelemahan deferred join tetap ada pada pemanggilan COUNT(*) untuk menghitung total baris.