Pada tabel yang terus membesar, OFFSET pagination biasanya menjadi sumber masalah performa yang terasa nyata: halaman awal masih cepat, tetapi page tinggi makin lambat, urutan hasil kadang tidak stabil, dan beban query meningkat. Untuk kasus feed, timeline, daftar aktivitas, atau daftar transaksi yang terus bertambah, cursor pagination di Laravel umumnya lebih cocok karena database tidak perlu menghitung lalu melewati banyak baris setiap kali pindah halaman.

Intinya, cursor pagination mengganti pola "lompat ke halaman ke-N" menjadi "ambil data setelah item terakhir yang sudah saya lihat". Pendekatan ini lebih efisien untuk dataset besar, terutama jika pengurutan dan indeksnya benar. Namun, ada syarat penting: kolom pengurutan harus stabil, idealnya unik atau dibuat unik secara efektif dengan kombinasi seperti created_at dan id.

Kapan OFFSET Pagination Mulai Bermasalah

OFFSET cocok untuk banyak kasus sederhana, terutama tabel kecil atau halaman admin internal dengan jumlah data terbatas. Masalah muncul ketika tabel tumbuh besar dan query berjalan terus-menerus pada urutan yang sama, misalnya feed artikel, log audit, notifikasi, atau riwayat transaksi.

Gejala nyata di produksi

  • Page tinggi makin lambat. Query LIMIT 20 OFFSET 200000 memaksa database melewati banyak baris sebelum mengembalikan 20 data.
  • Beban query membesar. Walaupun hasil yang diambil sedikit, pekerjaan internal database tetap besar.
  • Sort tidak stabil. Jika urutan hanya berdasarkan kolom yang tidak unik, data bisa terduplikasi atau terlewat antar halaman.
  • Hasil berubah saat ada insert baru. Pada feed aktif, data baru yang masuk di depan dapat menggeser posisi baris sehingga halaman berikutnya tidak konsisten.

Contoh pola OFFSET di Laravel

// Pola umum paginate berbasis OFFSET
$posts = Post::query()
    ->orderByDesc('created_at')
    ->paginate(20);

Secara konseptual, query SQL-nya akan mirip seperti ini:

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

Untuk halaman awal, ini sering masih terasa aman. Tetapi untuk halaman tinggi, database tetap harus memproses banyak baris sebelum sampai ke offset yang diminta. Pada feed besar, pola ini cenderung tidak efisien.

Cara Kerja Cursor Pagination

Cursor pagination tidak memakai OFFSET. Sebagai gantinya, aplikasi mengirim penunjuk posisi berdasarkan nilai kolom urutan dari item terakhir pada halaman sebelumnya. Query berikutnya menjadi "ambil 20 data setelah titik ini".

Contoh konsep query

Misalkan feed diurutkan dari terbaru ke terlama berdasarkan created_at dan id:

SELECT *
FROM posts
WHERE (created_at < '2025-06-01 10:00:00')
   OR (created_at = '2025-06-01 10:00:00' AND id < 98765)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Secara praktis, ini jauh lebih efisien karena database bisa langsung mulai dari posisi yang relevan, terutama bila ada indeks yang sesuai. Ia tidak perlu menghitung lalu melewati ratusan ribu baris terlebih dahulu.

Contoh di Laravel

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

Laravel akan menghasilkan URL pagination berbasis cursor, bukan nomor halaman biasa. Ini cocok untuk API feed, infinite scroll, timeline, dan daftar data besar yang diakses berurutan.

Catatan: Cursor pagination tidak ditujukan untuk kebutuhan utama "langsung lompat ke halaman 500". Jika antarmuka Anda sangat bergantung pada nomor halaman absolut, OFFSET mungkin masih diperlukan. Untuk feed besar, biasanya kebutuhan itu tidak dominan.

Syarat Pengurutan yang Aman: Jangan Hanya Mengandalkan Kolom yang Tidak Unik

Kesalahan paling umum saat migrasi ke cursor pagination adalah memakai urutan yang tidak stabil. Misalnya hanya ORDER BY created_at DESC, padahal banyak baris bisa memiliki created_at yang sama. Akibatnya, data bisa duplikat atau terlewat saat pengguna memuat halaman berikutnya.

Masalah jika urutan tidak unik

Anggap ada beberapa post dengan timestamp identik sampai detik yang sama. Jika cursor hanya menyimpan created_at, maka posisi antar baris dengan nilai sama menjadi ambigu. Database tahu kelompok timestamp tersebut, tetapi tidak punya aturan pasti untuk memilih urutan internal yang konsisten.

Solusi yang aman

Gunakan urutan gabungan yang menghasilkan posisi unik dan stabil, misalnya:

  • ORDER BY created_at DESC, id DESC
  • ORDER BY published_at DESC, id DESC
  • ORDER BY score DESC, id DESC jika memang feed diurutkan berdasarkan skor

Dengan pendekatan ini, jika created_at sama, id menjadi tie-breaker. Ini penting bukan hanya untuk cursor pagination, tetapi juga untuk menjaga konsistensi urutan hasil secara umum.

Contoh Eloquent yang benar

$posts = Post::query()
    ->where('status', 'published')
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->cursorPaginate(20);

Secara konseptual, SQL-nya akan menyerupai:

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

Untuk halaman berikutnya, kondisi cursor akan menambahkan pembatas berdasarkan kombinasi created_at dan id.

Index yang Sesuai: created_at Saja atau created_at,id?

Cursor pagination baru memberi hasil maksimal jika urutan dan indeks saling mendukung. Tanpa indeks yang tepat, query tetap bisa mahal meskipun sudah tidak memakai OFFSET.

Minimal: indeks pada kolom urutan utama

Jika Anda mengurutkan berdasarkan created_at, indeks pada created_at adalah dasar yang penting. Ini membantu database menavigasi urutan waktu dengan lebih efisien.

Lebih aman untuk urutan gabungan: indeks komposit

Jika query menggunakan:

ORDER BY created_at DESC, id DESC

maka indeks komposit pada (created_at, id) sering lebih tepat daripada hanya indeks tunggal di created_at. Alasannya, database dapat memanfaatkan struktur indeks yang sesuai dengan pola pengurutan dan pencarian cursor.

Contoh migrasi indeks

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

Jika feed juga selalu difilter, pertimbangkan urutan indeks berdasarkan pola query nyata. Misalnya bila hampir semua query adalah:

WHERE status = 'published'
ORDER BY created_at DESC, id DESC

maka dalam banyak kasus indeks komposit yang memasukkan status bisa relevan. Namun, desain indeks harus mengikuti query produksi yang paling sering dipakai, bukan sekadar menambahkan banyak indeks tanpa evaluasi. Setiap indeks punya biaya tulis dan biaya penyimpanan.

Prinsip praktis: indeks sebaiknya selaras dengan kombinasi WHERE dan ORDER BY yang benar-benar digunakan oleh endpoint feed.

Mengganti OFFSET Pagination ke Cursor Pagination di Laravel

Sebelum

public function index()
{
    $posts = Post::query()
        ->where('status', 'published')
        ->orderByDesc('created_at')
        ->paginate(20);

    return view('posts.index', compact('posts'));
}

Sesudah

public function index()
{
    $posts = Post::query()
        ->where('status', 'published')
        ->orderByDesc('created_at')
        ->orderByDesc('id')
        ->cursorPaginate(20);

    return view('posts.index', compact('posts'));
}

Perubahan kuncinya:

  • paginate() diganti menjadi cursorPaginate().
  • Urutan dibuat stabil dengan menambahkan id sebagai tie-breaker.
  • Indeks disesuaikan dengan pola urutan dan filter.

Untuk API JSON

Cursor pagination sangat natural untuk API karena klien cukup menyimpan cursor dari respons sebelumnya lalu memakainya untuk request berikutnya. Ini umum dipakai pada mobile app atau infinite scroll di web.

public function index()
{
    return Post::query()
        ->where('status', 'published')
        ->orderByDesc('created_at')
        ->orderByDesc('id')
        ->cursorPaginate(20);
}

Di sisi klien, pola yang dipakai bukan lagi ?page=2, tetapi URL atau parameter cursor yang diberikan oleh respons sebelumnya.

Perbandingan Performa Secara Konseptual

OFFSET pagination

  • Mudah dipahami.
  • Cocok untuk daftar kecil atau kebutuhan lompat ke halaman tertentu.
  • Semakin mahal pada page tinggi karena database harus melewati banyak baris.
  • Rentan hasil tidak konsisten pada feed yang aktif berubah.

Cursor pagination

  • Lebih efisien untuk dataset besar dan akses berurutan.
  • Lebih stabil pada feed yang terus menerima data baru.
  • Sangat cocok untuk infinite scroll dan API.
  • Kurang cocok jika UI sangat bergantung pada nomor halaman absolut.

Jadi, jika kasus Anda adalah feed besar yang terus bertambah, cursor pagination biasanya lebih masuk akal. Jika kasus Anda adalah tabel admin kecil dengan kebutuhan page number navigation yang jelas, OFFSET masih bisa diterima.

Jebakan Umum dan Cara Debug

1. Data duplikat atau terlewat

Penyebab paling sering adalah urutan tidak unik atau berubah-ubah. Pastikan ada tie-breaker yang konsisten, misalnya created_at lalu id.

2. Query masih lambat setelah pakai cursor

Biasanya masalah ada pada indeks. Cursor pagination bukan pengganti indeks. Periksa apakah kolom pada WHERE dan ORDER BY didukung indeks yang relevan.

3. Mengurutkan dengan kolom yang nilainya sering berubah

Jika feed diurutkan berdasarkan kolom yang sering diperbarui, posisi item bisa berpindah antar request. Ini dapat menyebabkan pengalaman paginasi terasa aneh. Untuk feed besar, urutan berbasis waktu publikasi atau ID sering lebih stabil dibanding skor yang berubah terus-menerus, kecuali Anda memang siap menangani dinamika itu.

4. Menggabungkan cursor dengan orderBy yang tidak konsisten

Semua request untuk endpoint yang sama harus memakai urutan yang identik. Jika ada cabang kode yang kadang menambah orderBy lain atau mengubah arah sort, cursor sebelumnya bisa menjadi tidak valid secara logis.

5. Salah mengukur performa

Jangan hanya melihat waktu respons aplikasi. Periksa juga query yang dijalankan, rencana eksekusi, dan apakah database memakai indeks yang diharapkan. Pada praktiknya, perbaikan cursor pagination paling terasa pada page tinggi dan trafik query berulang ke feed besar.

Checklist Migrasi Aman di Produksi

  1. Identifikasi endpoint yang bermasalah
    Fokus pada feed, timeline, log, notifikasi, atau daftar besar yang page tingginya sering diakses.
  2. Pastikan urutan stabil
    Jangan hanya memakai created_at jika nilainya tidak unik. Tambahkan id sebagai tie-breaker.
  3. Tambahkan indeks yang sesuai
    Minimal indeks pada kolom urutan utama. Untuk urutan gabungan, pertimbangkan indeks komposit seperti (created_at, id).
  4. Uji hasil halaman berurutan
    Verifikasi tidak ada item duplikat atau hilang saat berpindah cursor.
  5. Uji pada data yang aktif berubah
    Simulasikan insert baru saat pengguna sedang melakukan paginasi untuk memastikan perilaku tetap masuk akal.
  6. Periksa kontrak API dan frontend
    Frontend yang sebelumnya mengandalkan page=2 harus disesuaikan untuk mengikuti cursor dari respons.
  7. Rollout bertahap
    Bila memungkinkan, terapkan pada endpoint tertentu atau di balik feature flag agar mudah dibandingkan dan di-rollback.
  8. Monitor query dan error
    Pantau latensi endpoint, pola query database, dan laporan duplikasi/kehilangan item dari pengguna atau log aplikasi.

Kapan Tetap Memakai OFFSET

Cursor pagination bukan jawaban untuk semua kasus. OFFSET masih relevan jika:

  • dataset kecil atau sedang dan performa masih baik,
  • pengguna benar-benar perlu lompat ke halaman tertentu,
  • urutan data tidak cocok untuk cursor yang stabil,
  • halaman lebih bersifat administratif daripada feed real-time.

Namun untuk feed besar yang terus tumbuh, terutama yang diakses secara berurutan dari data terbaru ke lama, cursor pagination di Laravel adalah pilihan yang lebih efisien dan lebih stabil.

Penutup

Jika Anda melihat gejala seperti halaman tinggi makin lambat, query list makin berat, atau hasil pagination tidak konsisten pada data yang terus berubah, itu tanda kuat bahwa OFFSET mulai tidak cocok. Migrasi ke cursor pagination di Laravel biasanya cukup sederhana di level kode, tetapi keberhasilannya sangat bergantung pada dua hal: urutan yang stabil dan indeks yang sesuai.

Mulailah dari endpoint feed yang paling berat, ubah urutan menjadi deterministik seperti created_at DESC, id DESC, tambahkan indeks yang relevan, lalu uji perilaku di bawah data yang aktif berubah. Dengan langkah ini, Anda tidak hanya mengurangi beban query, tetapi juga membuat feed besar tetap cepat dan konsisten saat tabel terus membesar.