Jika halaman daftar di aplikasi Laravel mulai lambat saat pengguna membuka page yang tinggi, masalahnya sering bukan pada jumlah data yang ditampilkan per halaman, tetapi pada cara database mencari posisi awal data. Offset pagination memaksa database melewati banyak baris sebelum mengembalikan hasil, dan pada tabel besar biaya ini makin terasa. Di sisi lain, cursor pagination mengambil data berdasarkan posisi baris terakhir yang sudah dibaca, sehingga query cenderung tetap ringan meski data terus bertambah.

Artikel ini fokus pada kasus nyata: query page tinggi makin berat, hasil urutan tidak stabil saat data berubah, dan beban COUNT/OFFSET di dataset besar. Kita akan bahas perbedaan offset vs cursor, syarat kolom pengurutan yang aman, index yang tepat, contoh implementasi di Eloquent dan query builder, cara membaca EXPLAIN, serta langkah migrasi bertahap tanpa merusak API yang sudah dipakai klien.

Kenapa offset pagination mulai bermasalah di tabel besar

Offset pagination biasanya terlihat sederhana dan nyaman dipakai:

$users = User::orderBy('created_at', 'desc')
    ->paginate(50);

Di balik API yang sederhana, query semacam ini biasanya diterjemahkan menjadi pola SQL dengan LIMIT dan OFFSET. Untuk halaman awal, biayanya masih kecil. Tetapi ketika pengguna membuka page 1000, database tetap perlu memproses baris-baris sebelum offset tersebut agar bisa sampai ke data yang diminta.

Gejalanya biasanya seperti ini:

  • Halaman awal cepat, halaman tinggi lambat.
  • Beban database naik saat banyak klien membuka daftar data yang dalam.
  • Urutan tidak konsisten jika ada insert/update selama pengguna berpindah halaman.
  • Query total count mahal jika tetap butuh jumlah total halaman pada tabel yang sangat besar.

Masalah ini bukan hanya soal ukuran hasil per halaman. Bahkan jika Anda hanya mengambil 20 atau 50 baris, offset besar tetap memaksa database berjalan lebih jauh dari yang terlihat di output.

Perbedaan offset pagination vs cursor pagination

Offset pagination

Pada offset pagination, Anda meminta data berdasarkan nomor halaman. Misalnya page 3 dengan 20 item per halaman berarti database akan melewati 40 baris pertama, lalu mengambil 20 berikutnya.

Kelebihan:

  • Mudah dipahami.
  • Cocok untuk UI dengan nomor halaman eksplisit: 1, 2, 3, 4.
  • Mudah dipakai bila pengguna memang perlu melompat ke halaman tertentu.

Kekurangan:

  • Biaya query naik saat offset makin besar.
  • Rentan hasil bergeser jika ada data baru masuk di tengah navigasi.
  • Sering disertai query count untuk total halaman, yang bisa mahal pada tabel besar.

Cursor pagination

Pada cursor pagination, klien tidak meminta page=100, melainkan meminta data setelah item terakhir tertentu. Secara konsep, database tidak perlu menghitung dan melewati ribuan baris. Ia cukup mencari titik awal berdasarkan nilai kolom urut, lalu mengambil sejumlah baris setelahnya.

Di Laravel, pola ini tersedia lewat cursorPaginate().

Kelebihan:

  • Lebih efisien untuk navigasi maju pada data besar.
  • Lebih stabil pada dataset yang sering berubah, selama sort dirancang dengan benar.
  • Tidak memerlukan offset besar.

Kekurangan:

  • Tidak cocok jika UX harus mendukung lompat langsung ke halaman 200.
  • Membutuhkan kolom pengurutan yang aman dan deterministik.
  • Lebih menuntut disiplin pada desain sort dan index.

Kapan cursor pagination di Laravel layak dipakai

Cursor pagination cocok ketika:

  • Data terus bertambah, misalnya log, transaksi, notifikasi, order, event, atau feed admin.
  • Pengguna lebih sering klik next/previous daripada melompat ke nomor halaman tertentu.
  • Query halaman tinggi mulai terasa lambat.
  • Anda tidak benar-benar butuh total halaman secara real-time.

Offset pagination masih cukup ketika:

  • Tabel relatif kecil.
  • Data jarang berubah.
  • UX sangat bergantung pada nomor halaman dan total page count.
  • Pengguna sering lompat ke halaman tertentu dari daftar panjang.

Syarat utama: kolom pengurutan harus aman dan stabil

Kesalahan paling umum saat pindah ke cursor pagination adalah tetap memakai sort yang tidak deterministik. Cursor membutuhkan urutan yang stabil dan unik secara efektif, agar tidak ada baris yang hilang atau muncul ganda saat berpindah halaman.

Masalah pada sort yang tidak unik

Contoh yang berisiko:

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

Jika banyak baris memiliki nilai created_at yang sama, urutan antarbaris dengan timestamp identik bisa ambigu. Akibatnya, saat mengambil halaman berikutnya, Anda bisa melihat data lompat, duplikat, atau ada item yang tidak pernah muncul.

Pola sort yang aman

Gunakan kombinasi kolom yang menghasilkan urutan total, misalnya created_at DESC, id DESC. Kolom kedua berfungsi sebagai tie-breaker.

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

Ini jauh lebih aman karena jika dua baris punya created_at yang sama, id tetap membedakan posisinya.

Pilihan kolom yang umum dipakai

  • id jika urutan mengikuti insertion order dan ID monoton naik.
  • created_at + id untuk urutan berdasarkan waktu pembuatan.
  • published_at + id untuk konten yang dipublikasikan.

Hindari memakai kolom yang sering berubah sebagai dasar cursor, misalnya updated_at untuk daftar yang aktif di-update. Secara teknis bisa dipakai, tetapi konsistensi antarhalaman akan lebih sulit dijaga karena posisi item dapat berubah selama pengguna sedang menelusuri hasil.

Index yang tepat untuk cursor pagination

Cursor pagination tidak otomatis cepat jika index database tidak mendukung urutan dan filter yang dipakai. Prinsip dasarnya: index harus sejalan dengan WHERE dan ORDER BY.

Contoh kasus sederhana

Jika query Anda seperti ini:

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

Maka index yang lebih masuk akal adalah index gabungan yang diawali oleh kolom filter lalu kolom urut, misalnya konsep seperti:

INDEX(status, created_at, id)

Tujuannya agar database bisa:

  1. Menyaring data berstatus published.
  2. Menavigasi urutan berdasarkan created_at dan id.
  3. Mengambil beberapa baris berikutnya tanpa scanning besar.

Jika Anda hanya mengandalkan index pada id sementara query utama disaring oleh status dan diurutkan oleh created_at, optimizer mungkin tetap harus melakukan pekerjaan ekstra.

Contoh migration index di Laravel

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

Nama index bisa dibiarkan otomatis atau ditentukan manual sesuai kebutuhan operasional Anda.

Implementasi cursor pagination di Laravel

Eloquent: mengganti paginate menjadi cursorPaginate

Untuk kasus dasar, perubahan di level aplikasi sering cukup kecil:

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

Di view atau API response, Laravel akan menyertakan cursor untuk navigasi berikutnya atau sebelumnya. Mekanisme ini berbeda dari nomor halaman tradisional, jadi klien perlu membaca parameter cursor yang diberikan, bukan membentuk angka page sendiri.

Query builder

$posts = DB::table('posts')
    ->where('status', 'published')
    ->orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')
    ->cursorPaginate(20);

Prinsipnya sama: urutan harus konsisten dan sejalan dengan index.

Contoh endpoint API

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

    $posts = Post::query()
        ->where('status', 'published')
        ->orderBy('created_at', 'desc')
        ->orderBy('id', 'desc')
        ->cursorPaginate($perPage);

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

Batasi per_page agar klien tidak meminta ukuran yang terlalu besar dan merusak performa.

Bagaimana cursor pagination bekerja secara konsep

Secara konseptual, saat halaman pertama dikembalikan, Laravel menyimpan informasi posisi item terakhir sebagai cursor terenkripsi/terenkode. Saat klien meminta halaman berikutnya, query tidak lagi berbunyi “lewati 10.000 baris”, tetapi kurang lebih “ambil 20 baris setelah kombinasi created_at dan id tertentu”.

Pola ini memungkinkan database memulai dari titik yang lebih dekat ke data target, terutama jika ada index yang mendukung. Karena itu, biaya query biasanya lebih stabil daripada offset pada page yang sangat dalam.

Konsistensi data: kenapa cursor biasanya lebih aman

Pada offset pagination, dataset yang berubah di antara dua request bisa menggeser posisi data. Contoh: pengguna membuka page 1, lalu ada 10 data baru masuk di atas. Saat ia membuka page 2, sebagian item dari page 1 bisa muncul lagi, atau beberapa item terlewat.

Cursor pagination tidak menghilangkan semua masalah konsistensi, tetapi dengan sort yang stabil, ia biasanya lebih tahan terhadap perubahan tersebut karena halaman berikutnya dihitung dari posisi data terakhir yang benar-benar dilihat pengguna, bukan dari nomor halaman statis.

Trade-off-nya:

  • Jika kolom sort berubah setelah data tampil, posisi item dapat berpindah.
  • Jika Anda memakai sort berbasis kolom mutable, hasil antarrequest bisa tetap membingungkan.

Jadi, selain memilih cursor, Anda juga perlu memilih dasar urutan yang tidak sering berubah.

Membaca EXPLAIN untuk memverifikasi query

Jangan mengandalkan asumsi. Setelah migrasi ke cursor pagination, periksa rencana eksekusi query dengan EXPLAIN di database Anda.

Apa yang perlu diperhatikan

  • Index yang dipakai. Pastikan optimizer benar-benar memakai index yang sesuai dengan filter dan urutan.
  • Jumlah baris yang diperkirakan dibaca. Semakin kecil semakin baik, terutama dibanding query offset lama.
  • Operasi sorting tambahan. Jika database masih perlu sort besar di luar index, performa bisa belum optimal.
  • Full scan. Jika terjadi scan tabel penuh pada query daftar yang sering dipakai, biasanya index belum tepat atau urutan query tidak sejalan dengan index.

Pola evaluasi sederhana

Bandingkan dua query:

  1. Query lama dengan LIMIT/OFFSET pada page tinggi.
  2. Query baru berbasis cursor dengan urutan yang sama.

Lalu cek:

  • Apakah query baru memakai index yang diharapkan?
  • Apakah estimasi pembacaan baris turun?
  • Apakah waktu eksekusi lebih konsisten pada navigasi dalam?

Jika belum membaik, evaluasi lagi kombinasi WHERE, ORDER BY, dan struktur index.

Kesalahan umum saat migrasi

1. Tetap memakai sort yang ambigu

Misalnya hanya orderBy('created_at') tanpa id sebagai tie-breaker. Ini sumber bug paling sering.

2. Tidak menambah index yang relevan

Mengganti paginate() ke cursorPaginate() tanpa meninjau index sering membuat hasil tidak optimal. Cursor membantu strategi navigasi, tetapi database tetap butuh jalur akses yang efisien.

3. Memaksa UX lama ke model cursor

Jika frontend masih ingin menampilkan nomor halaman 1 sampai 500, cursor pagination bisa terasa janggal. Cursor lebih cocok untuk alur next/previous, infinite scroll, atau daftar admin yang fokus pada data terbaru.

4. Tetap menuntut total count real-time

Jika Anda masih selalu menghitung total data untuk setiap request, sebagian keuntungan performa bisa hilang. Pertimbangkan apakah total count benar-benar dibutuhkan di endpoint tersebut.

Strategi migrasi bertahap tanpa merusak API

Migrasi terbaik biasanya tidak dilakukan dengan mengganti perilaku endpoint lama secara diam-diam, terutama jika API sudah dipakai aplikasi mobile, frontend terpisah, atau integrasi pihak ketiga.

Opsi 1: endpoint baru

Buat endpoint baru khusus cursor pagination, misalnya:

  • /api/posts tetap offset-based
  • /api/posts-feed atau versi baru seperti /api/v2/posts memakai cursor

Ini paling aman untuk kompatibilitas.

Opsi 2: mode dipilih lewat parameter

Misalnya klien mengirim:

/api/posts?pagination=cursor

Server lalu mengembalikan format metadata yang sesuai. Cara ini fleksibel, tetapi Anda perlu jelas mendokumentasikan perbedaan response.

Opsi 3: migrasi internal lebih dulu

Untuk halaman admin internal, Anda bisa memigrasikan satu daftar yang paling bermasalah terlebih dahulu, ukur dampaknya, lalu lanjutkan ke daftar lain.

Langkah praktis migrasi

  1. Identifikasi query daftar yang paling lambat pada page tinggi.
  2. Tentukan urutan yang stabil, misalnya created_at DESC, id DESC.
  3. Tambahkan index gabungan yang sesuai.
  4. Ubah implementasi ke cursorPaginate().
  5. Sesuaikan frontend/API consumer agar membaca cursor.
  6. Bandingkan dengan EXPLAIN dan log performa.
  7. Roll out bertahap, bukan sekaligus ke semua endpoint.

Contoh pola migrasi dari offset ke cursor

Sebelum

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

    $posts = Post::query()
        ->where('status', 'published')
        ->orderBy('created_at', 'desc')
        ->paginate($perPage);

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

Sesudah

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

    $posts = Post::query()
        ->where('status', 'published')
        ->orderBy('created_at', 'desc')
        ->orderBy('id', 'desc')
        ->cursorPaginate($perPage);

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

Perubahan kode tampak kecil, tetapi secara operasional Anda harus memastikan dua hal: sort aman dan index sesuai. Tanpa itu, migrasi hanya setengah selesai.

Trade-off UX yang perlu disepakati sejak awal

Cursor pagination bukan pengganti universal. Ia sangat baik untuk performa dan konsistensi navigasi linear, tetapi ada kompromi pada pengalaman pengguna.

  • Lebih cocok untuk tombol Next/Previous, feed, timeline, infinite scroll.
  • Kurang cocok untuk kebutuhan “pergi ke halaman 37”.
  • Tidak ideal jika bisnis memerlukan total halaman presisi pada setiap request.

Karena itu, keputusan teknis sebaiknya melibatkan kebutuhan produk. Jangan memaksa cursor pada antarmuka yang benar-benar butuh offset semantics.

Checklist: kapan cursor pagination layak dipakai, kapan offset masih cukup

Pakai cursor pagination jika:

  • Halaman tinggi pada daftar mulai lambat.
  • Tabel besar dan terus bertambah.
  • Query count/offset menjadi beban.
  • Daftar diurutkan dengan kolom yang stabil dan bisa dibuat unik secara efektif.
  • Anda bisa menyediakan index gabungan yang sesuai.
  • UX cukup dengan next/previous atau infinite scroll.

Offset pagination masih cukup jika:

  • Volume data masih kecil atau sedang.
  • Page number dan total halaman adalah kebutuhan utama UX.
  • Pengguna sering melompat ke halaman tertentu.
  • Biaya query offset belum menjadi bottleneck nyata.

Penutup

Cursor pagination di Laravel adalah solusi praktis saat tabel besar membuat offset pagination makin berat, terutama pada page tinggi. Keuntungannya tidak hanya pada performa, tetapi juga pada stabilitas hasil saat data berubah. Namun, hasil yang baik bergantung pada tiga hal: urutan yang deterministik, index yang tepat, dan UX yang memang cocok dengan model cursor.

Jika daftar Anda lebih sering dibuka secara berurutan daripada dilompati berdasarkan nomor halaman, dan query mulai berat karena OFFSET serta COUNT, migrasi ke cursorPaginate() layak diprioritaskan. Mulailah dari endpoint yang paling bermasalah, ukur dengan EXPLAIN, lalu rollout bertahap agar perubahan aman bagi aplikasi dan konsumen API.