Jika daftar data di aplikasi Laravel mulai melambat ketika jumlah baris naik ke ratusan ribu atau jutaan, penyebab yang sering muncul adalah offset pagination. Query seperti LIMIT 50 OFFSET 500000 terdengar sederhana, tetapi database tetap harus menelusuri atau membuang banyak baris sebelum mengembalikan hasil.

Untuk kasus seperti orders, logs, atau audit_logs yang terus bertambah, pendekatan yang lebih stabil biasanya adalah cursor pagination ditambah index yang selaras dengan pola query. Di Laravel, ini berarti memahami kapan paginate() masih cukup, kapan cursorPaginate() lebih tepat, dan bagaimana mendesain index agar WHERE dan ORDER BY dapat dijalankan efisien oleh database.

Masalah utama: kenapa OFFSET makin mahal

Laravel paginate() pada umumnya menghasilkan query berbasis LIMIT dan OFFSET. Untuk halaman awal, ini biasanya masih cepat. Masalah muncul ketika pengguna membuka halaman yang jauh, atau ketika API melakukan iterasi data lama pada tabel yang terus tumbuh.

Contoh SQL yang umum:

SELECT id, customer_id, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 100000;

Secara logis, database harus menemukan urutan data yang benar, lalu melewati 100.000 baris sebelum mengambil 50 berikutnya. Walaupun ada index, OFFSET tetap memaksa database untuk berjalan lebih jauh di hasil yang sudah diurutkan.

Gejala yang sering terlihat:

  • Halaman 1 cepat, halaman 2000 lambat.
  • CPU database naik saat traffic ke endpoint list meningkat.
  • Query time tidak stabil meski ukuran halaman kecil.
  • EXPLAIN menunjukkan pemindaian baris yang jauh lebih besar dari jumlah hasil yang diminta.

Intinya: ukuran page bukan satu-satunya faktor. Pada offset pagination, posisi halaman juga menentukan biaya query.

paginate() vs cursorPaginate() di Laravel

Kapan paginate() masih tepat

paginate() tetap masuk akal jika:

  • Jumlah data belum besar.
  • Pengguna benar-benar butuh navigasi nomor halaman, misalnya halaman 1, 2, 3, 4.
  • Query relatif sederhana dan halaman yang diakses biasanya dekat awal.
  • Anda butuh total jumlah baris untuk UI, misalnya “1-50 dari 18.240 data”.

Contoh Eloquent:

$orders = Order::query()
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->paginate(50);

Perlu diingat, offset pagination sering juga disertai query COUNT(*) untuk menghitung total halaman. Pada tabel besar, ini dapat menjadi biaya tambahan tersendiri.

Kapan cursorPaginate() lebih tepat

cursorPaginate() cocok ketika tujuan utama adalah mengambil data berikutnya secara konsisten dan cepat, tanpa biaya offset yang membesar. Alih-alih mengatakan “lompat ke halaman 2000”, cursor pagination mengatakan “ambil 50 baris setelah item terakhir yang saya lihat”.

Contoh Eloquent:

$orders = Order::query()
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->cursorPaginate(50);

Secara konsep, Laravel akan mengirim cursor yang merepresentasikan posisi terakhir. Query lanjutan akan diterjemahkan menjadi kondisi seperti:

SELECT id, customer_id, status, created_at
FROM orders
WHERE status = 'paid'
  AND (
    created_at < '2025-01-10 12:34:56'
    OR (created_at = '2025-01-10 12:34:56' AND id < 987654)
  )
ORDER BY created_at DESC, id DESC
LIMIT 50;

Di sini database tidak perlu menghitung dan membuang 100.000 baris. Ia cukup melanjutkan dari titik terakhir sesuai urutan index.

Perbedaan praktis yang perlu dipahami

  • paginate(): mendukung nomor halaman, tapi makin mahal saat offset membesar.
  • cursorPaginate(): lebih efisien untuk tabel besar dan data yang terus bertambah, tapi UX nomor halaman hilang.
  • paginate() sering bergantung pada COUNT(*) untuk metadata total.
  • cursorPaginate() lebih cocok untuk infinite scroll, tombol next/previous, atau API list.

Kenapa index harus mengikuti WHERE + ORDER BY

Banyak kasus lambat bukan hanya karena memilih paginate(), tetapi karena index tidak cocok dengan pola query. Menambahkan index tunggal pada setiap kolom sering tidak cukup. Database membutuhkan index yang membantu filtering sekaligus pengurutan.

Misalnya query utama Anda seperti ini:

SELECT id, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50;

Index yang lebih relevan biasanya adalah index komposit yang urutannya mengikuti pola akses:

(status, created_at, id)

Kenapa urutannya penting?

  • status dipakai untuk menyaring terlebih dahulu.
  • created_at dipakai untuk urutan utama.
  • id menjadi tie-breaker agar urutan stabil ketika beberapa baris punya created_at yang sama.

Tanpa id sebagai pengurutan kedua, cursor pagination bisa menghasilkan urutan yang tidak stabil atau melewatkan/ menggandakan data saat banyak baris memiliki timestamp identik.

Contoh migrasi index di Laravel

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->index(['status', 'created_at', 'id'], 'orders_status_created_id_idx');
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex('orders_status_created_id_idx');
        });
    }
};

Jika Anda memakai database besar di production, perhatikan bahwa pembuatan index dapat memakan waktu dan berpotensi menahan lock tergantung engine, versi database, dan cara migrasi dijalankan. Untuk sistem sibuk, rencanakan pembuatan index dengan strategi yang aman, bukan asal menjalankan migration pada jam ramai.

Contoh bottleneck nyata pada tabel yang terus tumbuh

Kasus 1: orders untuk dashboard operasional

Tim operasional membuka daftar order berstatus paid dan shipped setiap hari. Awalnya query ini cepat:

$orders = Order::query()
    ->where('status', 'paid')
    ->latest('created_at')
    ->paginate(50);

Setelah data membesar, halaman awal masih masuk akal, tetapi ketika user mencari order lama melalui page tinggi, respons naik tajam. Solusi umumnya:

  1. Pastikan urutan stabil: orderByDesc('created_at')->orderByDesc('id').
  2. Tambahkan index komposit (status, created_at, id).
  3. Jika UI tidak butuh nomor halaman, pindahkan ke cursorPaginate(50).

Kasus 2: logs atau audit_logs

Tabel log biasanya append-only, terus bertambah, dan hampir selalu diakses dengan pola “yang terbaru dulu”. Ini adalah kandidat sangat kuat untuk cursor pagination.

$logs = AuditLog::query()
    ->where('tenant_id', $tenantId)
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->cursorPaginate(100);

Index yang relevan:

(tenant_id, created_at, id)

Untuk tabel seperti ini, offset pagination sering cepat rusak karena pengguna atau API sering menelusuri data jauh ke belakang. Cursor pagination jauh lebih sejalan dengan sifat datanya.

Contoh implementasi Laravel yang aman dipakai

Gunakan urutan yang deterministik

Ini penting untuk semua pagination, terutama cursor. Hindari hanya mengurutkan berdasarkan kolom yang nilainya bisa sama untuk banyak baris.

$query = Order::query()
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->orderByDesc('id');

Lebih aman daripada:

$query = Order::query()
    ->where('status', 'paid')
    ->orderByDesc('created_at');

Karena tanpa id sebagai urutan kedua, dua order dengan timestamp sama dapat menyebabkan hasil tidak konsisten antar halaman.

Contoh endpoint API

public function index(Request $request)
{
    $perPage = min((int) $request->integer('per_page', 50), 100);

    $orders = Order::query()
        ->select(['id', 'customer_id', 'status', 'total', 'created_at'])
        ->when($request->filled('status'), function ($query) use ($request) {
            $query->where('status', $request->string('status'));
        })
        ->orderByDesc('created_at')
        ->orderByDesc('id')
        ->cursorPaginate($perPage);

    return OrderResource::collection($orders);
}

Catatan praktis:

  • Pilih kolom yang benar-benar dibutuhkan dengan select().
  • Batasi per_page agar client tidak meminta 1000 baris sekaligus.
  • Pastikan filter yang dipakai juga dipertimbangkan dalam desain index.

Membaca EXPLAIN secara dasar

Jangan mengandalkan tebakan. Jalankan EXPLAIN pada query SQL yang benar-benar dieksekusi. Fokusnya bukan menjadi ahli optimizer, tetapi cukup untuk menjawab: apakah database memakai index yang sesuai, dan berapa banyak baris yang harus dibaca?

Contoh:

EXPLAIN
SELECT id, customer_id, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50;

Hal-hal yang perlu diperhatikan secara umum:

  • Index yang dipakai: apakah planner memilih index komposit yang Anda harapkan.
  • Estimasi baris: jika terlalu besar untuk query kecil, kemungkinan filtering atau ordering tidak efisien.
  • Operasi sort tambahan: jika database masih perlu melakukan sort terpisah, index mungkin tidak mendukung ORDER BY.
  • Full scan: jika seluruh tabel atau bagian besar tabel dibaca, index mungkin tidak dipakai atau tidak selektif.

Tanda-tanda index tidak terpakai

  • Query tetap lambat walau index baru sudah dibuat.
  • EXPLAIN menunjukkan scan besar dan sort tambahan.
  • Urutan kolom pada index tidak cocok dengan WHERE lalu ORDER BY.
  • Anda memfilter pada kolom A, tapi index dimulai dari kolom B.
  • Query memakai fungsi pada kolom yang diindex, sehingga optimizer sulit memakai index secara efektif.

Contoh masalah umum:

WHERE DATE(created_at) = '2025-01-10'

Ekspresi seperti ini sering membuat index pada created_at kurang berguna. Lebih aman ubah menjadi rentang:

WHERE created_at >= '2025-01-10 00:00:00'
  AND created_at < '2025-01-11 00:00:00'

Trade-off UX cursor pagination

Secara performa, cursor pagination sering lebih baik. Tetapi dari sisi produk dan UI, ada konsekuensi yang harus diterima.

  • Tidak ada nomor halaman tradisional.
  • Tidak ideal untuk fitur “lompat ke halaman 120”.
  • Total halaman biasanya tidak tersedia secara alami.
  • Lebih cocok untuk timeline, activity feed, log viewer, dan API list.

Karena itu, keputusan teknis sebaiknya mengikuti kebutuhan UX:

  • Pakai paginate() jika pengguna butuh page number dan total count, serta dataset masih terkendali.
  • Pakai cursorPaginate() jika prioritasnya adalah performa dan traversal berurutan pada data besar.

Aturan praktis: jika pengguna membaca data secara berurutan dari terbaru ke lama, cursor pagination biasanya lebih masuk akal daripada offset pagination.

Kesalahan yang sering terjadi

1. Mengganti paginate() ke cursorPaginate() tanpa memperbaiki ORDER BY

Cursor pagination butuh urutan yang stabil dan unik secara praktis. Jika Anda hanya mengurutkan berdasarkan created_at, hasil bisa tidak konsisten.

2. Menambah banyak index tunggal, bukan satu index komposit yang relevan

Index pada status, index pada created_at, dan index pada id tidak selalu seefektif index komposit (status, created_at, id) untuk query tertentu.

3. Mengabaikan biaya COUNT

Pada offset pagination, bottleneck bukan hanya OFFSET. Query count untuk total halaman juga bisa mahal pada tabel besar.

4. Select semua kolom

SELECT * memperbesar I/O dan bisa mengurangi efisiensi. Ambil hanya kolom yang dibutuhkan oleh list.

5. Tidak menguji query riil

Optimasi sebaiknya didasarkan pada query nyata dari endpoint production-like, bukan asumsi atau contoh query yang terlalu sederhana.

Checklist migrasi aman tanpa downtime besar

Jika Anda ingin memindahkan endpoint list dari offset pagination ke cursor pagination pada tabel besar, lakukan bertahap.

  1. Identifikasi endpoint yang paling mahal
    Lihat slow query log, APM, atau metrik database. Fokus pada endpoint list dengan offset tinggi atau count mahal.
  2. Pastikan pola query final sudah jelas
    Tentukan filter utama, urutan, dan kolom yang diambil. Index harus dirancang dari query final, bukan sebaliknya.
  3. Tambahkan index yang sesuai lebih dulu
    Buat index komposit untuk WHERE + ORDER BY. Perhatikan strategi pembuatan index yang aman untuk ukuran tabel dan engine database Anda.
  4. Verifikasi dengan EXPLAIN
    Cek apakah planner memakai index yang diharapkan dan apakah estimasi pembacaan baris turun.
  5. Gunakan urutan stabil
    Tambahkan id atau kolom unik lain sebagai tie-breaker di ORDER BY.
  6. Rilis endpoint cursor secara bertahap
    Untuk API, Anda bisa menambah mode baru dulu, misalnya endpoint atau parameter khusus, sebelum mengganti default.
  7. Sesuaikan UI
    Ganti page number dengan next/previous atau infinite scroll jika memang pindah ke cursor pagination.
  8. Pantau duplikasi atau data terlewat
    Terutama jika urutan belum deterministik atau data sering berubah saat pengguna sedang melakukan paging.
  9. Hapus index lama yang benar-benar tidak dipakai
    Lakukan setelah observasi, bukan langsung. Terlalu banyak index juga menambah biaya write.

Rekomendasi keputusan teknis

Untuk tabel Laravel yang terus tumbuh, keputusan paling aman biasanya seperti ini:

  • Jika dataset kecil atau kebutuhan UX sangat bergantung pada nomor halaman: tetap gunakan paginate().
  • Jika tabel append-heavy seperti orders, logs, atau audit_logs mulai membesar dan akses utamanya berurutan: pindah ke cursorPaginate().
  • Jangan berhenti di level Laravel saja; pastikan ada index komposit yang mendukung filter dan urutan query.
  • Validasi perbaikan dengan EXPLAIN, bukan perasaan.

Poin terpentingnya: cursor pagination bukan pengganti ajaib untuk semua kasus. Ia bekerja baik ketika pola akses data memang berurutan. Jika digabung dengan desain index yang benar, Anda bisa menghilangkan bottleneck offset yang umum muncul pada tabel yang terus tumbuh.