Masalah Nyata: API Listing Makin Lambat di Halaman Tinggi
Pada banyak aplikasi Laravel, endpoint daftar data awalnya terasa baik-baik saja. Misalnya endpoint orders, transactions, audit logs, atau activity feed dengan implementasi sederhana seperti Model::latest()->paginate(50). Saat jumlah data masih ribuan, respons cepat dan gejala masalah belum terlihat.
Masalah mulai muncul ketika tabel tumbuh menjadi ratusan ribu hingga jutaan baris. Pengguna atau sistem integrasi memanggil halaman tinggi seperti ?page=500 atau ?page=5000, lalu API tiba-tiba melambat. Gejalanya biasanya berupa waktu query membesar, CPU database naik, dan throughput API turun meskipun payload per halaman tetap sama.
Ini bukan sekadar persoalan “pagination itu berat”, tetapi lebih spesifik: offset pagination menjadi semakin mahal saat offset membesar. Di Laravel, pola ini umumnya datang dari paginate().
public function index(Request $request)
{
$perPage = min((int) $request->get('per_page', 50), 100);
$orders = Order::query()
->where('status', 'paid')
->orderByDesc('created_at')
->paginate($perPage);
return response()->json($orders);
}Secara fungsional kode di atas benar. Namun pada tabel besar, implementasi ini bisa menjadi bottleneck performa jika konsumen API sering melompat ke halaman yang jauh.
Kenapa paginate() Bisa Berat pada Tabel Besar
Offset memaksa database “melewati” banyak baris
Offset pagination biasanya diterjemahkan menjadi query seperti:
SELECT *
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 50 OFFSET 4950;Untuk mengembalikan 50 baris di halaman tinggi, database tetap perlu menelusuri atau memproses sejumlah besar baris sebelum sampai ke baris yang diminta. Secara konsep, OFFSET 4950 berarti database harus melewati 4.950 baris dulu, baru mengembalikan 50 berikutnya.
Pada offset kecil, biaya ini masih dapat diterima. Pada offset besar, biaya traversal menjadi mahal. Jika query melibatkan filter, sort, atau join tambahan, efeknya makin terasa.
Ada biaya tambahan dari query count
Di Laravel, paginate() tidak hanya mengambil data halaman saat ini, tetapi juga biasanya menjalankan query untuk menghitung total baris. Ini berguna untuk metadata seperti total, last_page, dan navigasi halaman numerik.
Masalahnya, pada tabel besar dengan kondisi filter kompleks, COUNT(*) sendiri bisa mahal. Jadi overhead pagination bukan hanya dari LIMIT/OFFSET, tetapi juga dari kebutuhan menghitung total data.
Sorting yang tidak didukung indeks membuatnya lebih buruk
Jika Anda melakukan ORDER BY created_at DESC tetapi tidak memiliki indeks yang sesuai, database mungkin harus melakukan sort terhadap banyak baris. Kombinasi sort mahal + offset besar adalah pola klasik yang membuat endpoint listing runtuh ketika trafik meningkat.
Catatan penting: gejala ini biasanya baru terlihat di production, karena data development sering terlalu kecil untuk memunculkan masalah nyata.
Kapan Cursor Pagination Lebih Cocok
Jika endpoint Anda adalah listing berurutan dan konsumen API lebih sering menekan tombol “next” atau melakukan infinite scroll daripada melompat ke halaman 173, maka cursor pagination biasanya lebih cocok dibanding offset pagination.
Di Laravel, pendekatan ini dapat menggunakan cursorPaginate(). Alih-alih berkata “ambil halaman ke-100”, cursor pagination berkata “ambil data setelah item terakhir yang tadi saya terima”. Secara database, ini biasanya diterjemahkan menjadi pola seek method:
SELECT *
FROM orders
WHERE status = 'paid'
AND (created_at, id) < ('2024-01-10 12:00:00', 150000)
ORDER BY created_at DESC, id DESC
LIMIT 50;Pola ini jauh lebih efisien karena database tidak perlu membuang ribuan atau jutaan baris akibat offset. Ia cukup melanjutkan dari titik terakhir yang sudah diketahui.
Contoh implementasi di Laravel:
public function index(Request $request)
{
$perPage = min((int) $request->get('per_page', 50), 100);
$orders = Order::query()
->where('status', 'paid')
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate($perPage);
return response()->json($orders);
}Perhatikan bahwa saya menambahkan orderByDesc('id') sebagai tie-breaker. Ini penting dan akan dibahas pada bagian berikutnya.
Syarat Penting: Sorting Harus Stabil dan Deterministik
Jangan hanya mengurutkan berdasarkan kolom yang bisa duplikat
Salah satu kesalahan umum saat pindah ke cursor pagination adalah hanya mengurutkan berdasarkan created_at. Pada sistem dengan traffic tinggi, banyak baris bisa memiliki nilai created_at yang sama sampai level detik atau bahkan mikrodetik tergantung implementasi. Jika urutannya tidak unik, halaman dapat berisi item yang lompat, ganda, atau hilang.
Karena itu, cursor pagination membutuhkan sorting yang stabil. Praktik yang aman adalah menambahkan kolom unik sebagai tie-breaker, biasanya id.
$orders = Order::query()
->where('status', 'paid')
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate(50);Dengan kombinasi tersebut, urutan menjadi deterministik: jika dua baris memiliki created_at sama, id menentukan posisi finalnya.
Pilih kolom sort yang sesuai dengan use case
Cursor pagination paling efektif untuk urutan yang alami dan konsisten, misalnya:
created_at DESC, id DESCuntuk feed terbaruid DESCuntuk log yang bertambah teruspublished_at DESC, id DESCuntuk artikel yang dipublikasikan
Kurang cocok jika pengguna bebas mengurutkan berdasarkan banyak kolom yang tidak terindeks atau tidak stabil, misalnya kombinasi pencarian dinamis dengan sort acak, sort berdasarkan agregasi, atau sort dari relasi yang kompleks.
Contoh Endpoint Sebelum dan Sesudah
Sebelum: offset pagination
Route::get('/api/orders', [OrderController::class, 'index']);
class OrderController
{
public function index(Request $request)
{
$perPage = min((int) $request->get('per_page', 50), 100);
$orders = Order::query()
->select(['id', 'customer_id', 'status', 'total', 'created_at'])
->where('status', 'paid')
->orderByDesc('created_at')
->paginate($perPage);
return response()->json($orders);
}
}Masalah dari endpoint ini:
- Lambat pada halaman tinggi
- Ada query count untuk total
- Rentan makin berat jika tabel terus tumbuh
Sesudah: cursor pagination
Route::get('/api/orders', [OrderController::class, 'index']);
class OrderController
{
public function index(Request $request)
{
$perPage = min((int) $request->get('per_page', 50), 100);
$orders = Order::query()
->select(['id', 'customer_id', 'status', 'total', 'created_at'])
->where('status', 'paid')
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate($perPage);
return response()->json([
'data' => $orders->items(),
'next_cursor' => optional($orders->nextCursor())->encode(),
'prev_cursor' => optional($orders->previousCursor())->encode(),
'per_page' => $orders->perPage(),
]);
}
}Di sisi API contract, perbedaannya cukup besar. Anda tidak lagi fokus pada page, last_page, atau total, tetapi pada token cursor untuk melanjutkan pembacaan data.
Indeks Database yang Perlu Disiapkan
Cursor pagination tidak akan optimal tanpa indeks yang sesuai. Jika query Anda memfilter berdasarkan status lalu mengurutkan berdasarkan created_at dan id, maka indeks komposit sangat penting.
Contoh kebutuhan query:
WHERE status = 'paid'
ORDER BY created_at DESC, id DESCMaka pertimbangkan indeks komposit yang selaras dengan pola akses tersebut:
CREATE INDEX idx_orders_status_created_id
ON orders (status, created_at, id);Beberapa catatan praktis:
- Urutan kolom indeks penting. Kolom filter yang selektif sering ditempatkan di depan, diikuti kolom sort.
- Pada beberapa database, arah
DESCpada indeks bisa relevan; pada yang lain optimizer cukup fleksibel. Yang penting, uji denganEXPLAINdi database Anda. - Jangan asal menambah banyak indeks. Setiap indeks punya biaya pada
INSERT,UPDATE, dan kebutuhan storage.
Selain indeks, hindari mengambil kolom berlebihan. Listing API sebaiknya memakai select() seperlunya, bukan otomatis * jika tabel memiliki banyak kolom besar.
Dampaknya ke Frontend dan API Consumer
Kontrak API berubah
Ini bagian yang sering diremehkan. Saat migrasi dari offset ke cursor, frontend atau integrator tidak bisa lagi mengandalkan URL seperti ?page=10. Mereka harus mengirim cursor yang diperoleh dari respons sebelumnya, misalnya ?cursor=....
Artinya, pola interaksi berubah dari:
- “ambil halaman ke-7”
- menjadi “ambil data setelah cursor ini”
Untuk aplikasi mobile, dashboard real-time, dan infinite scroll, ini biasanya baik. Untuk UI dengan kebutuhan lompat ke halaman tertentu, ini justru kurang ideal.
Tidak selalu ada total halaman
Karena tidak bergantung pada count total seperti offset pagination, API cursor umumnya tidak menyediakan metadata yang sama kaya seperti total, last_page, atau navigasi numerik. Jika frontend membutuhkan komponen paginator klasik 1, 2, 3, 4, 5, maka cursor pagination mungkin tidak cocok sebagai pengganti penuh.
Lebih tahan terhadap perubahan data, tapi bukan berarti sempurna
Pada sistem yang datanya terus bertambah, offset pagination bisa menghasilkan pengalaman yang aneh: item bergeser antar halaman karena ada insert baru. Cursor pagination biasanya lebih konsisten untuk melanjutkan pembacaan berurutan, tetapi tetap perlu dipahami bahwa dataset hidup dapat berubah selama pengguna melakukan paging.
Kapan Tetap Memakai paginate()
Cursor pagination bukan pengganti universal. Tetap gunakan paginate() jika:
- Pengguna memang perlu lompat ke halaman arbitrer
- UI memerlukan nomor halaman dan total halaman
- Dataset relatif kecil atau offset tidak pernah besar
- Sorting sangat dinamis dan sulit dibuat stabil
Untuk kasus tengah, Anda bisa mempertimbangkan memisahkan endpoint. Misalnya endpoint admin dengan navigasi halaman tetap memakai offset, sementara endpoint feed publik atau API integrasi ber-volume tinggi memakai cursor.
Kesalahan Umum dan Tips Debugging
1. Tidak mengecek query plan
Jangan menebak-nebak. Jalankan EXPLAIN pada query SQL hasil Eloquent. Pastikan database benar-benar memakai indeks yang diharapkan.
2. Sorting tidak unik
Jika hanya memakai created_at, hasil bisa tidak stabil. Tambahkan tie-breaker unik seperti id.
3. Menganggap semua endpoint listing cocok untuk cursor
Jika frontend butuh lompat ke halaman 200, cursor bukan jawaban yang nyaman.
4. Melupakan kompatibilitas API consumer
Perubahan dari page ke cursor adalah breaking change. Versioning API atau sediakan masa transisi bila consumer sudah banyak.
5. Fokus di Laravel, lupa bottleneck utamanya database
Sering kali masalah bukan pada framework, tetapi pada pola query dan indeks. Mengganti method pagination tanpa memperbaiki indexing belum tentu cukup.
Kesimpulan
Pada studi kasus API Laravel yang melambat saat nomor halaman tinggi, akar masalahnya sering berasal dari offset pagination. paginate() nyaman dan kaya metadata, tetapi biaya OFFSET dan COUNT dapat menjadi mahal pada tabel besar.
Untuk endpoint listing berurutan pada dataset besar, cursorPaginate() adalah solusi yang sering lebih efisien karena database tidak perlu melewati banyak baris untuk mencapai posisi tertentu. Namun implementasinya harus memenuhi syarat penting: sorting stabil, deterministik, dan didukung indeks yang tepat.
Di sisi lain, adopsi cursor pagination juga mengubah kontrak API dan cara frontend bekerja. Anda kehilangan navigasi halaman numerik, tetapi mendapatkan performa yang lebih konsisten pada workload listing skala besar. Jadi keputusan terbaik bukan “cursor selalu lebih baik”, melainkan pilih strategi pagination berdasarkan pola akses data, kebutuhan UI, dan karakter query Anda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!