Migrasi offset ke cursor pagination biasanya perlu dipertimbangkan saat query daftar yang dulu terasa ringan mulai melambat di produksi, terutama pada halaman-halaman dalam. Masalah utamanya bukan sekadar ukuran LIMIT, melainkan biaya membaca dan membuang baris sebanyak nilai OFFSET sebelum database bisa mengembalikan hasil yang diminta.
Solusi yang sering lebih pragmatis adalah kembali ke pola yang lebih dekat ke bottleneck nyata: jangan minta database “lompat ke baris ke-100000”, tetapi minta “ambil baris setelah item terakhir yang sudah saya lihat”. Itulah inti cursor pagination. Pendekatan ini bukan selalu lebih baik untuk semua kasus, tetapi sangat efektif untuk daftar yang terus tumbuh, diurutkan stabil, dan diakses secara berurutan.
Sudut pandang engineering pragmatisnya sederhana: sebelum menambah cache, sharding, atau trik rumit lain, cek dulu apakah bottleneck Anda berasal dari pola query yang memang tidak cocok untuk data besar.
Mengapa OFFSET/LIMIT mulai lambat saat data membesar
Secara logis, query seperti ini terlihat sederhana:
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Namun pada banyak database relasional, OFFSET 10000 berarti mesin tetap perlu berjalan melewati baris-baris sebelumnya agar bisa menemukan titik awal pengambilan data. Meskipun ada indeks yang membantu pengurutan, biaya membaca dan membuang baris tetap meningkat seiring nomor halaman.
Gejala yang umum muncul di produksi:
- Halaman 1 cepat, halaman 100 atau 1000 jauh lebih lambat.
- Latency endpoint list naik tanpa kenaikan kompleksitas bisnis.
- Beban I/O dan CPU database meningkat saat pengguna melakukan deep pagination.
- Query yang sama terlihat stabil di staging, tetapi memburuk di produksi karena distribusi data nyata jauh lebih besar.
- Sort dengan kolom non-unik menyebabkan hasil antarhalaman kadang duplikat atau lompat saat ada insert baru.
Pada skala kecil, biaya ini sering tidak terasa. Karena itu OFFSET/LIMIT tetap populer: mudah dipahami, mudah dipakai di UI nomor halaman, dan cukup baik untuk banyak dashboard internal atau dataset kecil. Masalahnya muncul ketika tabel terus membesar dan pola akses tetap sama.
Kapan sebaiknya migrasi offset ke cursor pagination
Anda tidak perlu mengganti semua pagination. Fokus pada endpoint yang memang menunjukkan gejala performa atau inkonsistensi hasil. Beberapa sinyal kuat bahwa sudah saatnya migrasi:
- Nomor halaman besar sering diakses, misalnya feed, riwayat transaksi, log aktivitas, notifikasi, atau katalog yang terus bertambah.
- Query diurutkan berdasarkan waktu atau ID dan pengguna umumnya menelusuri ke depan atau ke belakang, bukan melompat bebas ke halaman acak.
- Data sering berubah karena insert/delete/update, sehingga offset menghasilkan pengalaman yang tidak konsisten.
- EXPLAIN menunjukkan pemindaian banyak baris untuk mengambil sedikit hasil.
- P95/P99 latency endpoint list naik seiring pertumbuhan tabel, walaupun limit tetap kecil.
Sebaliknya, OFFSET masih layak dipakai bila:
- Dataset kecil atau dibatasi ketat.
- Pengguna benar-benar butuh jump to page 42.
- Urutan data relatif statis dan beban query rendah.
- Pagination dipakai untuk laporan admin sesekali, bukan jalur panas aplikasi.
Perbedaan model: OFFSET/LIMIT vs cursor pagination
Model OFFSET/LIMIT
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Kelebihan:
- Mudah dipahami.
- Cocok untuk UI berbasis nomor halaman.
- Sederhana di banyak framework dan ORM.
Kekurangan:
- Biaya meningkat seiring halaman makin dalam.
- Rentan hasil bergeser saat ada insert/delete di antara dua permintaan.
- Bisa menghasilkan duplikasi atau item terlewat jika urutan tidak benar-benar stabil.
Model cursor pagination
Pola dasarnya adalah mengambil hasil setelah nilai terakhir yang sudah diterima klien.
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ('2025-01-15 10:30:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 20;Di sini cursor berisi pasangan nilai urutan terakhir, misalnya created_at dan id. Database tidak perlu membuang 10000 baris; ia cukup melanjutkan dari titik yang diketahui.
Kelebihan:
- Lebih stabil untuk data besar.
- Lebih efisien untuk sequential access.
- Lebih konsisten saat ada insert/delete, jika kolom pengurutan dipilih dengan benar.
Kekurangan:
- Tidak natural untuk jump to arbitrary page.
- Implementasi API dan UI sedikit lebih kompleks.
- Butuh urutan yang stabil dan indeks yang sesuai.
Syarat kolom yang cocok untuk cursor
Cursor pagination bekerja baik bila Anda punya urutan yang:
- Stabil: hasil yang sama tidak berubah hanya karena urutan ambigu.
- Deterministik: setiap baris punya posisi jelas dalam urutan.
- Dapat diindeks: agar pencarian “setelah cursor” efisien.
Kolom yang umum dipakai:
idjika monoton dan menjadi urutan bisnis yang valid.created_atuntuk feed berdasarkan waktu, tetapi biasanya perlu tie-breaker.- Kombinasi
(created_at, id)untuk menangani banyak baris dengan timestamp sama.
Kesalahan yang sering terjadi adalah memakai kolom yang tidak unik sebagai satu-satunya cursor:
-- Kurang aman bila banyak baris punya created_at sama
WHERE created_at < '2025-01-15 10:30:00'
ORDER BY created_at DESC
LIMIT 20;Jika beberapa baris memiliki created_at identik, Anda berisiko kehilangan atau menggandakan baris antarhalaman. Karena itu, tambahkan tie-breaker unik:
SELECT id, created_at, title
FROM posts
WHERE (created_at < :cursor_created_at)
OR (created_at = :cursor_created_at AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;Secara logika, kondisi tersebut setara dengan “lanjutkan setelah baris terakhir yang tadi saya kirim”.
Dampak indeks pada ORDER BY + WHERE
Bagian terpenting dari migrasi offset ke cursor pagination adalah memastikan indeks mendukung dua hal sekaligus:
- filter
WHEREberbasis cursor, dan - urutan
ORDER BYyang dipakai query.
Untuk contoh query di atas, indeks komposit pada kolom urutan biasanya diperlukan:
CREATE INDEX idx_posts_created_id ON posts (created_at DESC, id DESC);Pada beberapa database, arah DESC dalam definisi indeks memiliki pengaruh spesifik; pada yang lain, mesin bisa tetap memanfaatkan indeks dua arah. Intinya bukan sintaksnya, melainkan kolom dan urutannya harus cocok dengan pola query.
Jika query Anda juga memfilter status, tenant, atau foreign key, indeks sering perlu disesuaikan:
SELECT id, created_at, title
FROM posts
WHERE tenant_id = :tenant_id
AND status = 'published'
AND (
created_at < :cursor_created_at
OR (created_at = :cursor_created_at AND id < :cursor_id)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;Maka indeks yang lebih cocok bisa berupa:
CREATE INDEX idx_posts_tenant_status_created_id
ON posts (tenant_id, status, created_at DESC, id DESC);Kenapa urutannya penting? Karena database lebih mudah menavigasi subset data yang relevan jika kolom penyaring paling selektif dan kolom pengurutan berada dalam jalur indeks yang sama. Jika indeks tidak cocok, mesin bisa jatuh ke sort mahal, pemindaian besar, atau kombinasi operasi yang tetap lambat walaupun sudah memakai cursor.
Contoh EXPLAIN yang perlu diperhatikan
Nama field EXPLAIN berbeda antar database, jadi fokus pada sinyal umumnya, bukan istilah vendor tertentu. Saat mengaudit query list, perhatikan hal-hal berikut:
- Jumlah baris yang dibaca jauh lebih besar daripada baris yang dikembalikan.
- Sort eksplisit masih terjadi walaupun Anda berharap indeks sudah cukup.
- Full scan atau range scan yang terlalu lebar untuk halaman kecil.
- Filter terjadi setelah pembacaan besar, bukan sejak awal melalui indeks.
Contoh sederhana yang perlu dicurigai pada OFFSET besar:
EXPLAIN
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Jika rencana eksekusinya menunjukkan pembacaan puluhan ribu atau lebih untuk mengembalikan 20 baris, itu sinyal kuat bahwa offset menjadi biaya dominan.
Lalu bandingkan dengan query cursor:
EXPLAIN
SELECT id, created_at, title
FROM posts
WHERE (created_at < '2025-01-15 10:30:00')
OR (created_at = '2025-01-15 10:30:00' AND id < 987654)
ORDER BY created_at DESC, id DESC
LIMIT 20;Yang Anda cari adalah rencana yang membaca subset lebih sempit dan tidak perlu membuang banyak baris di depan. Jika belum membaik, cek kembali indeks, urutan kolom, dan apakah predicate Anda memang sesuai dengan urutan sort.
Konsistensi data saat insert dan delete
Salah satu alasan kuat beralih ke cursor pagination adalah konsistensi hasil saat data berubah di antara request.
Masalah pada OFFSET
Misalkan pengguna membuka halaman 1, lalu ada 5 baris baru masuk di atas. Saat pengguna meminta halaman 2 dengan OFFSET 20, ia bisa melihat beberapa item yang sebelumnya sudah muncul, atau justru melewatkan item tertentu karena posisi baris bergeser.
Perilaku pada cursor
Dengan cursor, pengguna meminta “lanjutan setelah item terakhir yang tadi saya lihat”. Insert baru di atas biasanya tidak mengganggu kelanjutan halaman berikutnya. Ini membuat pengalaman membaca feed atau riwayat lebih stabil.
Namun cursor bukan berarti sempurna:
- Jika baris yang menjadi cursor dihapus, Anda tetap harus menangani kelanjutan berdasarkan nilai cursor yang sudah diserialisasi, bukan bergantung pada keberadaan baris itu.
- Jika kolom urutan bisa berubah, item dapat berpindah posisi. Untuk daftar yang butuh stabilitas tinggi, hindari menggunakan kolom yang sering diupdate sebagai dasar cursor.
- Pada level isolasi transaksi biasa, dua request terpisah tetap bisa melihat snapshot data berbeda. Cursor mengurangi masalah posisi, bukan menjamin snapshot global yang identik.
Praktik aman: gunakan kolom urutan yang relatif immutable, seperti created_at ditambah id, untuk feed append-only atau hampir append-only.
Desain API untuk cursor pagination
Pada API HTTP, cursor biasanya dikirim sebagai token opak agar klien tidak bergantung pada struktur internal. Secara internal token bisa berisi created_at dan id, lalu di-encode, misalnya dengan base64 atau format bertanda tangan jika perlu mencegah manipulasi.
Contoh respons:
{
"data": [
{ "id": 987654, "title": "...", "created_at": "2025-01-15T10:30:00Z" },
{ "id": 987653, "title": "...", "created_at": "2025-01-15T10:29:58Z" }
],
"paging": {
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xNVQxMDoyOTo1OFoiLCJpZCI6OTg3NjUzfQ==",
"has_more": true
}
}Contoh request berikutnya:
GET /api/posts?limit=20&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xNVQxMDoyOTo1OFoiLCJpZCI6OTg3NjUzfQ==Beberapa catatan desain:
- Jangan jadikan cursor kontrak yang terlalu transparan jika Anda ingin fleksibel mengubah implementasi.
- Batasi nilai limit agar satu request tidak membebani database.
- Sediakan arah navigasi yang jelas: hanya next, atau next/prev jika memang dibutuhkan.
- Dokumentasikan bahwa cursor terikat pada sort tertentu. Jika sort berubah, cursor lama bisa tidak valid.
Trade-off UX: kapan cursor terasa lebih buruk
Dari sisi performa, cursor pagination sering unggul. Dari sisi UX, tidak selalu.
Cursor pagination cocok untuk:
- Feed timeline.
- Riwayat transaksi.
- Log audit.
- Daftar notifikasi atau event stream.
Cursor pagination kurang cocok jika pengguna butuh:
- nomor halaman yang bisa dibagikan,
- lompat langsung ke halaman tertentu,
- indikator posisi absolut seperti “halaman 37 dari 1200”.
Karena itu, keputusan bukan murni teknis. Ada produk yang memakai pendekatan campuran: endpoint publik atau feed utama memakai cursor, sementara halaman admin atau pencarian terstruktur tetap memakai offset.
Checklist audit sebelum migrasi
- Apakah query list yang lambat memang memakai
OFFSETbesar? - Apakah urutan hasil saat ini stabil dan deterministik?
- Kolom apa yang paling cocok menjadi cursor:
id,created_at, atau kombinasi? - Apakah ada banyak nilai timestamp yang sama sehingga perlu tie-breaker unik?
- Apakah ada filter tambahan seperti
tenant_id,status, atau kategori yang harus masuk pertimbangan indeks? - Apakah EXPLAIN menunjukkan scan/sort yang besar?
- Apakah klien benar-benar memerlukan nomor halaman absolut?
- Apakah kontrak API saat ini bisa ditambah
next_cursortanpa memutus kompatibilitas? - Bagaimana menangani cursor yang rusak, kadaluarsa, atau tidak cocok dengan sort/filter?
- Apakah observability sudah cukup untuk membandingkan latency dan row scan sebelum-sesudah?
Langkah migrasi bertahap tanpa downtime
Migrasi offset ke cursor pagination sebaiknya dilakukan bertahap. Tujuannya mengurangi risiko pada performa, kompatibilitas API, dan perilaku klien.
1. Identifikasi endpoint prioritas
Jangan migrasikan semua list sekaligus. Mulai dari endpoint dengan kombinasi berikut: trafik tinggi, tabel besar, dan latency yang sudah memburuk.
2. Bekukan aturan sort
Tentukan urutan yang stabil, misalnya ORDER BY created_at DESC, id DESC. Jika sort berubah-ubah secara dinamis, definisikan dengan jelas sort mana yang didukung cursor dan mana yang tetap memakai offset.
3. Tambahkan indeks yang sesuai
Buat indeks lebih dulu. Pada sistem sibuk, rencanakan pembuatan indeks dengan cara yang aman sesuai kemampuan database Anda agar tidak mengganggu trafik tulis/baca.
4. Tambahkan jalur baca baru di API
Jangan langsung menghapus offset. Tambahkan mode cursor sebagai opsi baru, misalnya:
GET /api/posts?limit=20&cursor=...
GET /api/posts?limit=20&page=3Atau buat versi endpoint baru jika kontrak lama sulit diubah.
5. Uji kesetaraan hasil
Bandingkan halaman awal antara offset dan cursor untuk memastikan urutan dan isi data sesuai ekspektasi. Untuk pengujian, fokus pada:
- duplikasi item antarhalaman,
- item yang hilang saat banyak timestamp identik,
- perilaku saat insert/delete terjadi di tengah navigasi.
6. Rilis bertahap dengan observability
Aktifkan untuk sebagian trafik atau sebagian klien. Pantau:
- latency query dan endpoint,
- error decode/validate cursor,
- jumlah baris yang dibaca per query jika metrik tersedia,
- keluhan UI terkait navigasi dan load more.
7. Depresiasi offset pada jalur panas
Setelah jalur cursor stabil, pertahankan offset hanya untuk kasus yang memang membutuhkannya, misalnya admin table atau pencarian dengan nomor halaman.
Contoh implementasi SQL yang lebih aman
Untuk urutan descending dengan tie-breaker unik:
-- halaman pertama
SELECT id, created_at, title
FROM posts
WHERE tenant_id = :tenant_id
AND status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT :limit;
-- halaman berikutnya
SELECT id, created_at, title
FROM posts
WHERE tenant_id = :tenant_id
AND status = 'published'
AND (
created_at < :cursor_created_at
OR (created_at = :cursor_created_at AND id < :cursor_id)
)
ORDER BY created_at DESC, id DESC
LIMIT :limit;Jika urutan ascending, operator pembanding biasanya dibalik secara konsisten. Yang penting, logika WHERE harus sejalan dengan arah ORDER BY.
Kesalahan umum saat migrasi
- Cursor memakai kolom non-unik tanpa tie-breaker, menyebabkan item hilang atau duplikat.
- Indeks tidak cocok dengan filter + sort, sehingga cursor tetap lambat.
- Mengubah sort tetapi tetap menerima cursor lama, menghasilkan urutan kacau.
- Menganggap cursor menyelesaikan semua masalah konsistensi, padahal snapshot antarrequest tetap bisa berubah.
- Membuka limit terlalu besar, sehingga keuntungan cursor berkurang karena ukuran halaman sendiri terlalu berat.
- Token cursor mudah dimanipulasi tanpa validasi, memicu query aneh atau hasil tidak terduga.
Debugging jika cursor pagination belum terasa cepat
- Cek EXPLAIN lagi: apakah indeks yang diharapkan benar-benar dipakai?
- Pastikan predicate cursor sesuai arah sort.
- Lihat apakah ada filter tambahan yang membuat indeks komposit saat ini tidak efektif.
- Periksa apakah query masih melakukan sort di luar indeks.
- Bandingkan jumlah baris dibaca vs dikembalikan.
- Ukur dari aplikasi juga: jangan-jangan bottleneck ada di serialisasi respons, N+1 query, atau fetch data relasi.
Ini sejalan dengan pendekatan pragmatis: kembali ke solusi yang sederhana dan terukur, lalu pastikan Anda memang memperbaiki bottleneck yang nyata, bukan sekadar mengganti pola karena tren.
Kapan OFFSET masih layak dipertahankan
Walaupun banyak sistem besar beralih ke cursor, OFFSET tidak perlu dianggap anti-pola mutlak. Tetap pakai OFFSET bila:
- halaman yang diakses umumnya dangkal,
- jumlah total data kecil atau terfilter sempit,
- kebutuhan UX lebih cocok dengan nomor halaman,
- biaya operasional migrasi lebih besar daripada manfaatnya.
Prinsipnya bukan “cursor selalu lebih modern”, melainkan pilih pola yang paling sederhana yang masih menyelesaikan bottleneck nyata.
Penutup
Migrasi offset ke cursor pagination layak dilakukan ketika query list mulai melambat karena biaya deep pagination, atau ketika hasil antarhalaman makin tidak konsisten akibat insert/delete. Kunci keberhasilannya ada pada urutan yang stabil, pemilihan kolom cursor yang tepat, indeks yang selaras dengan WHERE dan ORDER BY, serta migrasi API yang bertahap.
Mulailah dari audit sederhana: lihat query paling panas, baca EXPLAIN, dan periksa apakah database menghabiskan banyak kerja hanya untuk membuang baris sebelum mencapai halaman yang diminta. Jika ya, cursor pagination sering menjadi perbaikan yang langsung menyentuh bottleneck sebenarnya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!