Offset pagination makin lambat saat tabel terus membesar karena database tidak bisa begitu saja “lompat” ke baris ke-100000 lalu mulai mengambil 20 data berikutnya. Dalam banyak kasus, mesin database tetap harus membaca, menyortir, atau melewati sejumlah besar baris terlebih dahulu sebelum mengembalikan hasil halaman yang diminta.
Di Laravel, gejalanya biasanya muncul pada halaman daftar admin, feed internal, atau endpoint API yang awalnya responsif tetapi semakin lambat seiring pertumbuhan data. Akar masalahnya bukan hanya pada LIMIT, melainkan kombinasi OFFSET, ORDER BY, dan desain index yang tidak mendukung pola akses query tersebut.
Kenapa OFFSET Pagination Makin Mahal
Query offset pagination umumnya berbentuk seperti ini:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;Secara logika, aplikasi hanya meminta 20 baris. Namun database harus mencari urutan data yang benar dulu, lalu melewati 100000 baris sebelum mengembalikan 20 baris berikutnya. Semakin besar offset, semakin besar kerja yang harus dilakukan.
Ada dua biaya utama di sini:
- Biaya scan: database membaca banyak baris atau entri index hanya untuk membuang sebagian besar hasil.
- Biaya sort: jika
ORDER BYtidak didukung index yang tepat, database perlu melakukan sorting tambahan sebelum menerapkanLIMIT/OFFSET.
Masalah ini sering tidak terlihat saat tabel masih berisi ribuan baris. Tetapi saat tabel menjadi jutaan baris, halaman 1 mungkin tetap cepat, sedangkan halaman 5000 menjadi jauh lebih lambat.
Contoh Gejala Nyata di Laravel
Misalnya Anda memakai Eloquent seperti ini:
$posts = Post::query()
->orderByDesc('created_at')
->orderByDesc('id')
->paginate(20);Laravel akan menghasilkan query offset pagination untuk halaman berikutnya. Untuk halaman kecil ini sering aman. Tetapi ketika pengguna membuka halaman sangat jauh, query menjadi mahal karena database tetap harus memproses semua baris sebelumnya.
ORDER BY Tanpa Index yang Tepat Membuat Masalah Lebih Parah
OFFSET mahal, tetapi kombinasi buruk dengan ORDER BY yang tidak didukung index jauh lebih mahal. Ini bottleneck yang sangat umum.
Contoh query:
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;Jika Anda sering memfilter dengan status lalu mengurutkan dengan created_at, id, index tunggal di created_at saja belum tentu cukup. Database mungkin masih harus membaca banyak baris yang tidak relevan atau melakukan sort tambahan.
Index yang Lebih Relevan
Untuk pola query di atas, index komposit berikut sering lebih masuk akal:
CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at, id);Tujuannya bukan sekadar “menambah index”, tetapi menyesuaikan urutan kolom index dengan pola filter dan sort query. Dengan index yang sesuai, database lebih mungkin:
- menemukan baris dengan
status = 'published'lebih cepat, - membaca hasil dalam urutan yang sudah sesuai,
- mengurangi kebutuhan sorting tambahan.
Namun perlu dicatat: index yang baik tidak menghilangkan biaya OFFSET besar. Index hanya mengurangi kerja pencarian dan sorting. Jika offset sudah sangat besar, database tetap harus melewati banyak entri.
Kesalahan yang Sering Terjadi
- Mengurutkan dengan kolom yang tidak stabil, misalnya hanya
created_attanpa tie-breaker sepertiid. Jika banyak baris punya timestamp sama, hasil pagination bisa tidak konsisten. - ORDER BY kolom tanpa index relevan, lalu heran kenapa halaman besar lambat.
- Menganggap LIMIT kecil pasti murah. LIMIT 20 tetap bisa mahal jika OFFSET-nya 100000.
Cara Membaca EXPLAIN: Lihat Full Scan, Filesort, dan Row Scan Besar
Jika sebuah daftar Laravel melambat, jangan menebak. Jalankan EXPLAIN pada query SQL yang benar-benar dieksekusi.
EXPLAIN
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;Nama kolom hasil EXPLAIN bisa sedikit berbeda tergantung database, tetapi beberapa sinyal umum tetap sama.
1. Full table scan atau scan sangat lebar
Jika rencana eksekusi menunjukkan database membaca sebagian besar tabel atau sangat banyak baris, itu tanda query tidak selektif atau index tidak membantu cukup jauh.
Hal yang perlu dicari:
- perkiraan jumlah baris yang besar,
- akses tipe scan penuh atau scan rentang yang sangat lebar,
- filter diterapkan terlambat setelah banyak data dibaca.
2. Filesort atau sort tambahan
Jika EXPLAIN menunjukkan sorting tambahan, artinya database tidak bisa sepenuhnya memanfaatkan urutan index untuk memenuhi ORDER BY. Ini sering terjadi ketika:
- urutan kolom index tidak cocok dengan query,
- filter dan sort tidak sejalan dengan index,
- kolom sort tidak unik sehingga database perlu kerja tambahan.
Catatan: istilah seperti filesort tidak selalu berarti menulis ke file disk. Itu biasanya menandakan operasi sort tambahan di luar urutan natural index.
3. Row scan besar meski hasil akhir kecil
Ini ciri khas masalah offset pagination. Query mengembalikan 20 baris, tetapi database harus memproses puluhan ribu atau ratusan ribu baris untuk sampai ke posisi yang diminta.
Jika Anda melihat pola seperti ini, optimasi di level aplikasi saja tidak cukup. Anda perlu mengubah strategi pagination atau akses data.
Offset Pagination vs Cursor Pagination vs Seek Method
Offset Pagination
Di Laravel, ini biasanya memakai paginate().
$posts = Post::query()
->where('status', 'published')
->orderByDesc('created_at')
->orderByDesc('id')
->paginate(20);Kelebihan:
- Mudah dipakai.
- Cocok untuk UI dengan nomor halaman: 1, 2, 3, dst.
- Mudah dipahami pengguna admin/backoffice.
Kekurangan:
- Semakin mahal untuk halaman jauh.
- Rentan hasil bergeser saat data baru masuk atau data lama terhapus di tengah navigasi.
- Tidak ideal untuk tabel yang terus tumbuh besar.
Cocok dipakai saat:
- dataset masih relatif kecil,
- pengguna memang butuh lompat ke halaman tertentu,
- beban query masih terukur dan stabil.
Cursor Pagination
Laravel menyediakan cursorPaginate() untuk menghindari OFFSET. Alih-alih “halaman ke-N”, klien membawa penanda posisi terakhir.
$posts = Post::query()
->where('status', 'published')
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate(20);Secara konsep, halaman berikutnya diambil berdasarkan nilai kolom pengurutan terakhir, bukan dengan menghitung offset besar.
Kelebihan:
- Jauh lebih efisien untuk navigasi maju pada dataset besar.
- Tidak perlu melewati baris dalam jumlah besar.
- Lebih stabil untuk feed atau API yang datanya terus berubah.
Kekurangan:
- Tidak natural untuk UI nomor halaman tradisional.
- Butuh urutan yang deterministik dan konsisten.
- Integrasi UX kadang perlu disesuaikan, misalnya tombol next/previous tanpa nomor halaman absolut.
Cocok dipakai saat:
- API list besar,
- infinite scroll,
- feed atau timeline,
- tabel yang terus bertambah dan lebih sering diakses secara berurutan daripada lompat acak ke halaman jauh.
Seek Method
Seek method adalah bentuk query berbasis posisi terakhir, biasanya diterapkan dengan kondisi WHERE pada kolom sort. Ini konsep yang sama dengan cursor pagination, tetapi bisa Anda kontrol manual.
Contoh SQL:
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
AND (
created_at < '2025-01-10 12:00:00'
OR (created_at = '2025-01-10 12:00:00' AND id < 987654)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;Kenapa ada dua kolom? Karena created_at saja bisa punya nilai sama pada banyak baris. id dipakai sebagai tie-breaker agar urutan deterministik dan tidak ada data loncat/duplikat.
Contoh Query Builder:
$cursorCreatedAt = '2025-01-10 12:00:00';
$cursorId = 987654;
$posts = DB::table('posts')
->select('id', 'title', 'created_at')
->where('status', 'published')
->where(function ($q) use ($cursorCreatedAt, $cursorId) {
$q->where('created_at', '<', $cursorCreatedAt)
->orWhere(function ($q) use ($cursorCreatedAt, $cursorId) {
$q->where('created_at', '=', $cursorCreatedAt)
->where('id', '<', $cursorId);
});
})
->orderByDesc('created_at')
->orderByDesc('id')
->limit(20)
->get();Kelebihan:
- Kontrol penuh atas query.
- Sangat efisien jika index sesuai.
- Cocok untuk kasus custom yang tidak pas dengan pagination tradisional.
Kekurangan:
- Lebih kompleks untuk diimplementasikan.
- Perlu hati-hati terhadap kondisi tie-breaker.
- Lebih sulit jika pengguna harus melompat ke halaman acak.
Desain Index yang Mendukung Pagination
Jika query Anda seperti ini secara konsisten:
SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;Maka desain index perlu mengikuti pola akses tersebut. Secara umum, index komposit pada kolom filter lalu kolom sort sering dibutuhkan.
CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at, id);Poin penting:
- Filter dulu, lalu sort: jika query selalu memfilter status, masuk akal menaruh
statusdi depan index. - Gunakan urutan deterministik: tambahkan
idsebagai tie-breaker jika kolom utama sort tidak unik. - Jangan membuat terlalu banyak index: setiap index menambah biaya write, update, dan storage.
Di Laravel migration, contohnya:
Schema::table('posts', function ($table) {
$table->index(['status', 'created_at', 'id'], 'idx_posts_status_created_id');
});Pastikan index yang ditambahkan benar-benar sesuai dengan query dominan. Menambah index yang tidak dipakai hanya menambah overhead tanpa manfaat.
Konsistensi Hasil Saat Data Berubah
Pagination bukan cuma soal cepat atau lambat. Saat data terus berubah, hasil yang dilihat pengguna juga bisa tidak konsisten.
Masalah pada offset pagination
Misalnya pengguna membuka halaman 1, lalu ada 10 data baru masuk sebelum ia membuka halaman 2. Dengan offset pagination, beberapa item bisa:
- muncul dua kali,
- terlewat,
- bergeser ke halaman lain.
Ini bukan bug Laravel, melainkan konsekuensi dari model offset pada data yang berubah di antara request.
Cursor/seek lebih stabil
Karena cursor atau seek method bergerak berdasarkan posisi item terakhir yang sudah dilihat, hasil biasanya lebih konsisten untuk alur “lanjut ke berikutnya”. Ini sangat berguna untuk API, feed, dan daftar event/log yang aktif berubah.
Namun tetap ada trade-off: cursor pagination tidak dirancang untuk pengalaman “lompat ke halaman 237” dengan akurat seperti offset pagination.
Kapan Harus Tetap Pakai Offset, dan Kapan Harus Migrasi
Tetap pakai offset jika:
- pengguna butuh nomor halaman absolut,
- jumlah data belum besar,
- akses ke halaman jauh jarang terjadi,
- query sudah didukung index yang cukup baik.
Pertimbangkan cursor pagination atau seek method jika:
- tabel tumbuh terus dan sudah besar,
- halaman jauh sering lambat,
- beban baca tinggi pada endpoint list,
- pengguna lebih sering menelusuri next/previous daripada lompat ke halaman tertentu,
- data sering berubah sehingga offset menghasilkan duplikasi atau item terlewat.
Implementasi Praktis di Laravel
Offset pagination standar
$posts = Post::query()
->where('status', 'published')
->orderByDesc('created_at')
->orderByDesc('id')
->paginate(20);Cursor pagination
$posts = Post::query()
->where('status', 'published')
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate(20);Saat memakai cursor pagination:
- pastikan urutan
ORDER BYdeterministik, - hindari sort ambigu,
- uji navigasi next/previous pada data yang berubah.
API response dan UX
Jika endpoint lama mengembalikan metadata seperti total halaman, berpindah ke cursor pagination bisa mengubah kontrak API. Cursor biasanya lebih cocok untuk respons seperti:
- data item,
- cursor berikutnya,
- indikator apakah masih ada data berikutnya.
Artinya migrasi bukan hanya perubahan query, tetapi juga perubahan desain antarmuka aplikasi.
Checklist Migrasi Aman di Produksi
- Identifikasi query list yang paling terdampak
Cari halaman atau endpoint dengan tabel besar dan akses berurutan tinggi.
- Pastikan ORDER BY deterministik
Tambahkan tie-breaker seperti
idjika perlu. - Evaluasi index terhadap pola filter + sort
Jangan hanya melihat satu kolom; lihat kombinasi query yang benar-benar dipakai.
- Bandingkan EXPLAIN sebelum dan sesudah
Lihat apakah scan berkurang dan operasi sort tambahan menurun.
- Ubah endpoint bertahap
Untuk API publik, pertimbangkan versi baru daripada mengubah perilaku secara diam-diam.
- Sesuaikan UX
Jika sebelumnya ada nomor halaman, pastikan pengguna tetap punya navigasi yang jelas saat beralih ke next/previous atau infinite scroll.
- Uji data yang berubah di tengah navigasi
Simulasikan insert/delete saat pengguna berpindah halaman untuk melihat efek duplikasi atau item hilang.
- Perhatikan biaya penambahan index
Index membantu baca, tetapi menambah overhead pada insert/update.
Debugging Cepat Saat Halaman Daftar Laravel Melambat
- Periksa apakah query memakai
paginate()dengan halaman sangat jauh. - Lihat SQL final dan jalankan
EXPLAIN. - Periksa apakah
ORDER BYsesuai dengan index yang tersedia. - Cari tanda scan besar atau sort tambahan.
- Pastikan urutan hasil unik dan stabil, misalnya
created_at, id. - Jika use case lebih cocok untuk navigasi berurutan, uji
cursorPaginate().
Kesimpulan
Masalah utama saat offset pagination makin lambat bukan karena Laravel semata, tetapi karena cara kerja SQL saat harus mengurutkan lalu melewati banyak baris pada tabel yang terus membesar. Index yang tepat bisa membantu, terutama jika ORDER BY dan filter didesain sesuai pola query, tetapi index tidak menghapus biaya offset yang sangat besar.
Jika aplikasi Anda lebih banyak menampilkan daftar besar yang diakses secara berurutan, cursor pagination atau seek method biasanya lebih cocok daripada offset pagination. Pilihan terbaik bergantung pada trade-off UX, kebutuhan nomor halaman, dan konsistensi hasil saat data berubah.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!