Pagination adalah cara membagi data besar menjadi beberapa bagian kecil. Hampir setiap katalog produk, daftar transaksi, halaman admin, dan endpoint API membutuhkannya. Masalahnya, pagination yang tampak berjalan baik pada 100 data bisa mulai berantakan ketika tabel berisi jutaan baris atau ketika data baru terus masuk.
Kesalahan yang sering terjadi bukan hanya soal tampilan. API bisa mengirim item yang sama dua kali, melewatkan data, atau menjadi semakin lambat ketika pengguna meminta halaman yang jauh. Karena itu, pilihan antara offset pagination dan cursor pagination sebaiknya ditentukan dari pola data dan kebutuhan aplikasi, bukan sekadar mengikuti contoh framework.
Offset pagination: mudah dipahami, tetapi punya batas
Offset pagination menggunakan nomor halaman atau jumlah baris yang harus dilewati. Contoh sederhananya:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;Query tersebut meminta 20 baris setelah melewati 40 baris pertama. Pada API, bentuknya biasanya seperti /posts?page=3&limit=20 atau /posts?offset=40&limit=20.
Kelebihannya jelas: implementasinya sederhana dan mudah dipahami pengguna. Nomor halaman juga cocok untuk tabel admin yang memungkinkan pengguna langsung melompat ke halaman tertentu. MySQL mendukung pola LIMIT dan OFFSET, dengan offset awal bernilai 0. Database juga dapat memanfaatkan indeks ketika query memiliki batas jumlah baris dan urutan yang sesuai.
Namun, offset bukan “gratis”. Untuk mencapai offset yang besar, database tetap perlu menemukan atau melewati banyak baris terlebih dahulu. Selain itu, hasil dapat berubah di antara dua request. Bayangkan pengguna sedang membaca halaman pertama, lalu ada artikel baru masuk di posisi teratas. Saat halaman kedua diminta, sebagian item bisa bergeser sehingga ada artikel yang muncul dua kali atau justru terlewat.
Cursor pagination: lebih stabil untuk data yang terus bergerak
Cursor pagination tidak memakai nomor halaman. Response API mengembalikan penanda posisi dari item terakhir, lalu penanda itu dikirim kembali untuk meminta data berikutnya.
Misalnya, daftar diurutkan berdasarkan tanggal terbaru dan id sebagai penentu tambahan:
SELECT id, title, created_at
FROM posts
WHERE created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;Cursor dapat berisi nilai created_at dan id tersebut, biasanya dalam bentuk token yang sudah dienkode. Klien tidak perlu mengetahui detail internalnya. Contoh response:
{
"data": [...],
"next_cursor": "eyJjcmVhdGVkX2F0Ijoi..."
}Request berikutnya cukup memakai /posts?cursor=eyJjcmVhdGVkX2F0Ijoi.... Karena query mencari data setelah posisi tertentu, bukan menghitung halaman dari awal, pendekatan ini biasanya lebih cocok untuk feed, log, notifikasi, dan transaksi yang terus bertambah.
Kunci pentingnya adalah urutan yang stabil
Pagination tidak akan konsisten jika urutannya tidak deterministik. Mengurutkan hanya berdasarkan created_at bisa bermasalah karena beberapa baris mungkin memiliki timestamp yang sama. Tambahkan kolom unik sebagai tie-breaker, misalnya id.
Gunakan pola yang sama pada tiga bagian:
- ORDER BY harus memiliki urutan yang jelas, misalnya
created_at DESC, id DESC. - WHERE pada cursor harus mengikuti arah urutan tersebut.
- Index sebaiknya mendukung kolom filter dan pengurutan yang paling sering digunakan.
Untuk contoh di atas, indeks gabungan seperti (created_at, id) dapat menjadi titik awal yang masuk akal. Tetapi jangan menebak-nebak. Periksa query dengan EXPLAIN dan lihat apakah database memakai rencana eksekusi yang sesuai. Indeks yang terlalu banyak juga menambah biaya saat data di-insert, di-update, atau dihapus.
Jangan menjadikan total halaman sebagai kewajiban
Pada offset pagination, aplikasi sering menghitung total data dengan COUNT(*) agar bisa menampilkan “Halaman 3 dari 120”. Informasi ini berguna pada beberapa dashboard, tetapi tidak selalu perlu untuk API publik atau feed tanpa akhir.
Jika kebutuhan pengguna hanya “muat data berikutnya”, response dapat memakai has_more atau next_cursor. Dengan begitu, server tidak harus menghitung jumlah seluruh hasil pada setiap request. Ini bukan aturan mutlak—total tetap bisa disediakan jika memang penting—tetapi sebaiknya diperlakukan sebagai fitur tambahan, bukan bagian wajib dari setiap endpoint.
Bagaimana memilih pendekatan yang tepat?
Gunakan offset jika:
- Data relatif stabil selama pengguna berpindah halaman.
- Pengguna perlu melompat ke halaman tertentu.
- Jumlah data tidak terlalu besar atau query sudah terbukti efisien.
- Endpoint digunakan untuk tabel admin dengan navigasi halaman tradisional.
Gunakan cursor jika:
- Data terus bertambah atau berubah saat pengguna membaca.
- Endpoint dipakai untuk feed, aktivitas, chat, log, atau transaksi.
- Anda ingin menghindari offset besar pada tabel yang tumbuh cepat.
- Konsistensi urutan lebih penting daripada kemampuan lompat ke halaman tertentu.
Yang bisa dilakukan sekarang
- Tentukan urutan data yang benar-benar dibutuhkan pengguna.
- Tambahkan kolom unik sebagai tie-breaker.
- Buat pengujian ketika ada data baru masuk di antara dua request.
- Uji endpoint dengan data sedikit dan data besar; jangan hanya mengandalkan database lokal.
- Periksa query menggunakan
EXPLAIN, terutama jika offset bisa mencapai ribuan atau lebih. - Batasi nilai
limitdari sisi server agar klien tidak meminta ribuan baris sekaligus. - Dokumentasikan arti cursor, masa berlaku token jika ada, serta perilaku ketika cursor sudah tidak valid.
Offset bukan pilihan yang salah, dan cursor bukan obat untuk semua masalah. Offset unggul dalam kesederhanaan dan navigasi halaman, sedangkan cursor lebih kuat ketika data bergerak cepat. Yang paling penting adalah memastikan urutan stabil, query dapat diuji, dan kontrak API jelas bagi siapa pun yang menggunakannya.
Sumber & bacaan lebih lanjut
- MySQL 8.4 Reference Manual: LIMIT Query Optimization
- MySQL 8.4 Reference Manual: SELECT Statement
- MySQL 8.4 Reference Manual: Optimization and Indexes
- MySQL 8.4 Reference Manual: Optimizing SELECT Statements
– Rio Yotto @rioyotto
