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 DESC untuk feed terbaru
  • id DESC untuk log yang bertambah terus
  • published_at DESC, id DESC untuk 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 DESC

Maka 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 DESC pada indeks bisa relevan; pada yang lain optimizer cukup fleksibel. Yang penting, uji dengan EXPLAIN di 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.