Laravel Cursor Pagination cocok dipakai saat listing atau feed mulai melambat karena query berbasis OFFSET harus melewati semakin banyak baris seiring pertumbuhan data. Pada tabel besar, masalahnya bukan hanya lambat: hasil halaman juga bisa lompat atau duplikat ketika ada data baru masuk atau data lama terhapus di tengah proses paginasi.

Jika Anda punya halaman admin, feed aktivitas, riwayat transaksi, atau API listing yang sering diakses, cursorPaginate() biasanya lebih stabil dan lebih ringan dibanding paginate() atau simplePaginate() untuk data berukuran besar. Kuncinya adalah memakai ordering yang stabil dan memastikan kolom pengurutan memiliki index yang sesuai.

Kapan OFFSET mulai jadi masalah

Paginasi tradisional biasanya memakai pola seperti berikut:

SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;

Query di atas terlihat sederhana, tetapi database tetap harus memproses banyak baris sebelum mengambil 20 data yang diminta. Semakin besar nilai OFFSET, semakin mahal query tersebut. Inilah sebabnya halaman awal terasa cepat, tetapi halaman jauh di belakang menjadi lambat.

Selain biaya query, ada masalah konsistensi hasil:

  • Insert baru di bagian atas daftar dapat menggeser hasil, sehingga item yang tadi ada di halaman 2 bisa pindah ke halaman 3.
  • Delete dapat membuat beberapa item terlewat atau muncul ganda saat pengguna pindah halaman.
  • Pada listing yang aktif berubah, offset pagination sulit memberikan pengalaman navigasi yang stabil.

Masalah ini sering muncul pada:

  • feed aktivitas atau notifikasi,
  • listing admin dengan jutaan baris,
  • API mobile yang melakukan infinite scroll,
  • riwayat event, log, atau transaksi.

Perbedaan paginate, simplePaginate, dan cursorPaginate

1. paginate()

paginate() cocok jika Anda butuh informasi total data dan nomor halaman lengkap.

$users = User::orderBy('id')->paginate(20);

Kelebihan:

  • Ada total item, total halaman, halaman saat ini, dan link nomor halaman.
  • Bagus untuk UI admin tradisional yang butuh navigasi halaman 1, 2, 3, dst.

Kekurangan:

  • Biasanya lebih mahal, karena selain query data, total count juga perlu dihitung.
  • Tetap berbasis OFFSET, sehingga makin berat pada halaman jauh.

2. simplePaginate()

simplePaginate() adalah versi lebih ringan dari paginate() karena tidak menghitung total data.

$users = User::orderBy('id')->simplePaginate(20);

Kelebihan:

  • Lebih ringan daripada paginate() karena tidak ada query total count.
  • Mudah dipakai pada listing sederhana.

Kekurangan:

  • Masih memakai OFFSET.
  • Tidak menyelesaikan masalah performa pada halaman jauh.

3. cursorPaginate()

cursorPaginate() tidak melompat ke halaman ke-N dengan OFFSET. Sebagai gantinya, Laravel memakai nilai kolom pengurutan terakhir sebagai titik lanjut.

$users = User::orderBy('id')->cursorPaginate(20);

Secara konsep, query berikut lebih dekat dengan cara kerja cursor pagination:

SELECT *
FROM users
WHERE id > 150000
ORDER BY id ASC
LIMIT 20;

Atau jika urutannya menurun:

SELECT *
FROM posts
WHERE id < 900000
ORDER BY id DESC
LIMIT 20;

Kelebihan:

  • Tidak perlu memproses baris sebanyak nilai OFFSET.
  • Lebih stabil untuk data yang sering berubah.
  • Sangat cocok untuk infinite scroll dan API feed.

Kekurangan:

  • Tidak mendukung navigasi halaman absolut seperti “langsung ke halaman 57”.
  • Membutuhkan ordering yang stabil dan dapat diindeks dengan baik.
  • Lebih cocok untuk alur next/previous daripada UI nomor halaman tradisional.

Syarat penting: ordering harus stabil

Kesalahan paling umum saat memakai Laravel Cursor Pagination adalah mengurutkan data dengan kolom yang tidak unik atau tidak stabil. Misalnya:

// Berisiko jika banyak baris punya created_at yang sama
Post::orderBy('created_at', 'desc')->cursorPaginate(20);

Jika banyak record memiliki created_at identik, batas antar halaman bisa ambigu. Solusi praktisnya adalah menambahkan tie-breaker yang unik, biasanya id.

$posts = Post::query()
    ->orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')
    ->cursorPaginate(20);

Dengan pola ini, urutan menjadi deterministik:

  • urutkan dulu berdasarkan created_at,
  • jika nilainya sama, pecahkan dengan id.

Catatan: kolom pengurutan sebaiknya tidak mudah berubah selama umur data listing. Jika Anda mengurutkan berdasarkan kolom yang sering di-update, posisi item dapat berubah antar request dan pengalaman paginasi menjadi kurang konsisten.

Memilih kolom untuk sorting

Pilihan yang umumnya aman:

  • id untuk urutan sederhana dan cepat,
  • created_at, id untuk feed terbaru,
  • published_at, id untuk konten terbit,
  • transaction_date, id untuk riwayat transaksi.

Hindari memakai:

  • kolom hasil fungsi atau ekspresi yang sulit diindeks,
  • kolom dengan banyak nilai duplikat tanpa tie-breaker unik,
  • kolom yang sering berubah seperti skor realtime tanpa desain index yang jelas.

Pentingnya index pada kolom sort

Cursor pagination baru terasa efektif jika database dapat memanfaatkan index untuk ORDER BY dan kondisi pembatasnya. Jika query mengurutkan berdasarkan created_at DESC, id DESC, pertimbangkan index komposit yang sejalan dengan pola query Anda.

Schema::table('posts', function ($table) {
    $table->index(['created_at', 'id']);
});

Di banyak kasus, index komposit membantu database mencari titik awal cursor dan mengambil batch berikutnya tanpa scan besar. Namun, Anda tetap perlu memverifikasi dengan EXPLAIN, bukan berasumsi bahwa semua index otomatis dipakai.

Tips memilih index

  • Samakan urutan kolom index dengan urutan ORDER BY yang paling sering dipakai.
  • Jika ada filter tetap, pertimbangkan hubungan antara kolom filter dan kolom sort.
  • Jangan menambah terlalu banyak index tanpa alasan; setiap index punya biaya write.
  • Uji query nyata di data produksi atau staging yang representatif.

Contoh: jika listing selalu difilter per tenant lalu diurutkan berdasarkan waktu terbaru, pola index bisa mengikuti kebutuhan itu.

// Contoh pola query
$logs = ActivityLog::query()
    ->where('tenant_id', $tenantId)
    ->orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')
    ->cursorPaginate(50);

Dalam skenario seperti ini, index yang mempertimbangkan tenant_id dan kolom sorting sering lebih relevan daripada index tunggal pada created_at saja.

Implementasi praktis di Laravel

Contoh Eloquent

use App\Models\Post;
use Illuminate\Http\Request;

class PostController
{
    public function index(Request $request)
    {
        $posts = Post::query()
            ->where('status', 'published')
            ->orderBy('created_at', 'desc')
            ->orderBy('id', 'desc')
            ->cursorPaginate(20);

        return response()->json($posts);
    }
}

Poin penting dari contoh di atas:

  • Ada filter yang jelas: hanya data published.
  • Ordering memakai dua kolom agar stabil.
  • Jumlah item per halaman tetap kecil dan realistis.

Contoh Query Builder

use Illuminate\Support\Facades\DB;

$rows = DB::table('orders')
    ->where('store_id', $storeId)
    ->orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')
    ->cursorPaginate(100);

Query Builder cocok jika Anda ingin query lebih eksplisit atau tidak butuh seluruh fitur model Eloquent.

Menjaga parameter filter di link pagination

Jika endpoint punya filter seperti status, tenant, atau kata kunci pencarian, pastikan parameter tersebut tetap ikut saat berpindah halaman.

$posts = Post::query()
    ->when($request->status, fn ($q, $status) => $q->where('status', $status))
    ->orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')
    ->cursorPaginate(20)
    ->appends($request->query());

Tanpa ini, pengguna bisa menerima link next yang kehilangan konteks filter.

Contoh respons API cursor pagination

Struktur respons akan bergantung pada cara Anda mengembalikan data, tetapi secara umum Anda akan melihat item data beserta URL atau parameter cursor untuk navigasi berikutnya/sebelumnya.

{
  "data": [
    {
      "id": 105,
      "title": "Post terbaru",
      "created_at": "2024-01-10T10:00:00Z"
    },
    {
      "id": 104,
      "title": "Post sebelumnya",
      "created_at": "2024-01-10T09:59:00Z"
    }
  ],
  "path": "https://api.example.com/posts",
  "per_page": 20,
  "next_cursor": "...",
  "next_page_url": "https://api.example.com/posts?cursor=...",
  "prev_cursor": "...",
  "prev_page_url": null
}

Dalam praktik frontend:

  • untuk infinite scroll, simpan next_page_url atau nilai cursor,
  • untuk tombol “Muat lebih banyak”, panggil endpoint berikutnya menggunakan cursor terakhir,
  • hindari asumsi adanya nomor halaman absolut.

Audit query lambat dengan EXPLAIN

Sebelum migrasi, audit dulu query yang memang bermasalah. Jangan mengganti semua pagination hanya karena asumsi. Gunakan EXPLAIN pada query SQL yang dihasilkan Laravel untuk melihat apakah database melakukan scan besar, memakai filesort, atau gagal memanfaatkan index yang tepat.

Langkah audit sederhana

  1. Aktifkan logging query di lokal atau staging.
  2. Ambil query listing yang lambat, terutama yang memakai LIMIT ... OFFSET ....
  3. Jalankan EXPLAIN langsung di database client.
  4. Bandingkan dengan versi query berbasis cursor.

Contoh kasar pola SQL yang perlu diaudit:

EXPLAIN SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;

Lalu bandingkan dengan pendekatan cursor:

EXPLAIN SELECT id, title, created_at
FROM posts
WHERE status = 'published'
  AND (created_at < '2024-01-10 10:00:00'
       OR (created_at = '2024-01-10 10:00:00' AND id < 105))
ORDER BY created_at DESC, id DESC
LIMIT 20;

Yang perlu diperhatikan saat membaca hasil EXPLAIN:

  • apakah index yang diharapkan benar-benar dipakai,
  • berapa banyak row yang diperkirakan dipindai,
  • apakah ada indikasi sort mahal di luar index,
  • apakah filter dan ordering sejalan dengan index yang tersedia.

Tip debugging: jika cursor pagination tetap lambat, masalahnya bisa jadi bukan pada paginasi, melainkan pada filter yang tidak terindeks, join yang berat, atau kolom seleksi yang terlalu banyak.

Trade-off UX dan keterbatasannya

Cursor pagination bukan jawaban untuk semua kasus. Ia unggul pada performa dan konsistensi list yang berubah cepat, tetapi ada konsekuensi pada UX.

Trade-off yang perlu dipahami

  • Tidak ideal untuk nomor halaman. Jika pengguna harus melompat ke halaman 100, offset-based pagination lebih natural.
  • Lebih cocok untuk next/previous. Sangat pas untuk feed, timeline, atau “load more”.
  • Tergantung ordering stabil. Jika urutan data sering berubah karena update kolom sort, hasil bisa terasa bergeser.
  • Cursor tidak ramah untuk manipulasi manual. URL dengan parameter cursor tidak seintuitif ?page=5.

Kapan tetap memilih paginate() atau simplePaginate()

  • UI admin membutuhkan total data dan nomor halaman.
  • Data belum terlalu besar dan performa masih memadai.
  • Pengguna sering perlu navigasi ke halaman tertentu.

Kapan cursorPaginate() lebih tepat

  • feed API dan infinite scroll,
  • listing yang aktif berubah,
  • tabel besar dengan halaman jauh yang mulai lambat,
  • endpoint yang sensitif terhadap latensi.

Kesalahan umum saat migrasi ke cursor pagination

  • Mengurutkan hanya dengan kolom non-unik seperti created_at tanpa id.
  • Tidak menambahkan index yang sesuai, lalu menyimpulkan cursor pagination tidak membantu.
  • Mencampur pola UX lama yang butuh nomor halaman dengan backend cursor tanpa desain ulang frontend.
  • Melupakan filter existing saat membentuk link next/previous.
  • Mengurutkan berdasarkan kolom yang sering berubah, sehingga posisi item tidak stabil.
  • Tetap memilih terlalu banyak kolom atau eager load relasi berlebihan pada endpoint yang seharusnya ringan.

Checklist migrasi aman dari offset ke cursor pagination

  1. Identifikasi endpoint yang benar-benar lambat. Fokus pada feed, log, transaksi, atau listing admin dengan volume besar.
  2. Catat pola query saat ini. Lihat filter, sort, join, dan kebutuhan total count.
  3. Tentukan apakah UX bisa diubah. Jika UI sangat bergantung pada nomor halaman, mungkin tidak semua endpoint cocok dimigrasikan.
  4. Pilih ordering yang stabil. Gunakan kolom unik atau kombinasi seperti created_at, id.
  5. Tambahkan atau revisi index. Samakan dengan pola filter dan sorting utama.
  6. Uji dengan EXPLAIN. Verifikasi bahwa query baru benar-benar lebih efisien.
  7. Perbarui respons API atau frontend. Ganti asumsi page menjadi cursor, next_page_url, atau tombol “load more”.
  8. Jaga kompatibilitas bertahap. Untuk API publik, pertimbangkan versi endpoint baru daripada mengubah perilaku lama secara mendadak.
  9. Monitoring setelah rilis. Pantau query time, error rate, dan keluhan pengguna terkait navigasi data.

Rekomendasi implementasi praktis

Jika masalah utama Anda adalah halaman listing yang makin lambat pada tabel besar, pendekatan paling aman biasanya:

  • mulai dari satu endpoint yang paling berat,
  • ubah ke cursorPaginate(),
  • gunakan orderBy('created_at', 'desc') ->orderBy('id', 'desc') atau id saja jika cukup,
  • tambahkan index yang sesuai,
  • ukur hasilnya lewat query log dan EXPLAIN.

Untuk halaman admin tradisional, Anda tidak harus mengganti semua pagination. Sering kali strategi terbaik adalah campuran:

  • paginate() untuk halaman yang butuh total dan nomor halaman,
  • simplePaginate() untuk offset ringan tanpa total count,
  • cursorPaginate() untuk feed besar yang sensitif terhadap performa.

Pada akhirnya, Laravel Cursor Pagination bekerja baik bukan karena ia “lebih modern”, tetapi karena ia mengubah cara database mengambil data: dari melompati banyak baris menjadi melanjutkan dari posisi terakhir yang sudah diketahui. Untuk tabel besar, perubahan kecil di level query ini sering memberi dampak nyata pada latensi, beban database, dan konsistensi hasil.