Paginasi kursor menjaga daftar tetap stabil saat data berubah. Pelajari mengapa paging offset rusak dengan insert dan delete, dan bagaimana mengimplementasikan kursor yang bersih.

Anda membuka feed, menggulir sedikit, dan semuanya terasa normal sampai tiba-tiba tidak. Anda melihat item yang sama dua kali. Sesuatu yang Anda yakini ada tiba-tiba hilang. Baris yang hendak Anda ketuk bergeser, dan Anda mendarat di halaman detail yang salah.
Ini adalah bug yang terlihat oleh pengguna, meskipun respons API Anda tampak “benar” jika dilihat sendiri. Gejala umum mudah dikenali:
Ini jadi lebih buruk di mobile. Orang berhenti sejenak, berpindah aplikasi, kehilangan koneksi, lalu melanjutkan. Selama itu, item baru masuk, item lama dihapus, dan beberapa diedit. Jika aplikasi Anda terus meminta “halaman 3” menggunakan offset, batas halaman bisa bergeser saat pengguna sedang menggulir. Hasilnya adalah feed yang terasa tidak stabil dan tidak dapat dipercaya.
Tujuannya sederhana: setelah pengguna mulai menggulir maju, daftar harus berperilaku seperti snapshot. Item baru boleh saja ada, tapi tidak boleh merombak ulang apa yang sudah sedang dilihat pengguna. Pengalaman harus mulus dan dapat diprediksi.
Tidak ada metode paginasi yang sempurna. Sistem nyata punya penulisan bersamaan, edit, dan beberapa opsi pengurutan. Tetapi paginasi kursor biasanya lebih aman daripada paginasi offset karena ia mempage dari posisi tertentu dalam urutan yang stabil, bukan dari hitungan baris yang terus bergerak.
Paginasi offset adalah cara “lewati N, ambil M” untuk mempage sebuah daftar. Anda memberi tahu API berapa banyak item yang harus dilewati (offset) dan berapa banyak yang dikembalikan (limit). Dengan limit=20, Anda mendapat 20 item per halaman.
Secara konseptual:
GET /items?limit=20\u0026offset=0 (halaman pertama)GET /items?limit=20\u0026offset=20 (halaman kedua)GET /items?limit=20\u0026offset=40 (halaman ketiga)Respons biasanya menyertakan item plus info yang cukup untuk meminta halaman berikutnya.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Cara ini populer karena sesuai dengan tabel, daftar admin, hasil pencarian, dan feed sederhana. Implementasinya mudah dengan SQL menggunakan LIMIT dan OFFSET.
Masalahnya adalah asumsi terselubung: dataset tetap diam saat pengguna mempage. Dalam aplikasi nyata, baris baru disisipkan, baris dihapus, dan kunci pengurutan berubah. Di sinilah bug "misterius" mulai muncul.
Paginasi offset mengasumsikan daftar tetap sama antara permintaan. Tapi daftar nyata bergerak. Saat daftar bergeser, offset seperti “lewati 20” tidak lagi menunjuk ke item yang sama.
Bayangkan feed diurutkan oleh created_at desc (terbaru di atas), ukuran halaman 3.
Anda memuat halaman 1 dengan offset=0, limit=3 dan mendapat [A, B, C].
Sekarang item baru X dibuat dan muncul di atas. Daftar sekarang [X, A, B, C, D, E, F, ...]. Anda memuat halaman 2 dengan offset=3, limit=3. Server melewati [X, A, B] dan mengembalikan [C, D, E].
Anda baru saja melihat C lagi (duplikat), dan nanti Anda akan melewatkan item karena semuanya bergeser ke bawah.
Hapus menyebabkan kegagalan kebalikan. Mulai dengan [A, B, C, D, E, F, ...]. Anda memuat halaman 1 dan melihat [A, B, C]. Sebelum halaman 2, B dihapus, sehingga daftar menjadi [A, C, D, E, F, ...]. Halaman 2 dengan offset=3 melewati [A, C, D] dan mengembalikan [E, F, G]. D menjadi celah yang tak pernah Anda ambil.
Dalam feed newest-first, insert terjadi di atas, yang tepat adalah apa yang menggeser setiap offset berikutnya.
"Daftar stabil" adalah yang diharapkan pengguna: saat mereka menggulir maju, item tidak meloncat, berulang, atau hilang tanpa alasan jelas. Ini lebih tentang membuat paging dapat diprediksi daripada membekukan waktu.
Dua gagasan sering tercampur:
created_at dengan tie-breaker seperti id) sehingga dua permintaan dengan input yang sama mengembalikan urutan yang sama.Refresh dan scroll-forward adalah tindakan berbeda. Refresh berarti "tunjukkan apa yang baru sekarang", jadi bagian atas bisa berubah. Scroll-forward berarti "lanjut dari tempat saya", jadi Anda tidak boleh melihat pengulangan atau celah tak terduga yang disebabkan oleh pergeseran batas halaman.
Aturan sederhana yang mencegah sebagian besar bug paginasi: scrolling forward tidak boleh menampilkan pengulangan.
Paginasi kursor bergerak melalui daftar menggunakan penanda bukmark daripada nomor halaman. Alih-alih “beri saya halaman 3”, klien mengatakan “lanjut dari sini.”
Kontraknya sederhana:
Ini lebih tahan terhadap insert dan delete karena kursor berlabuh pada posisi dalam urutan yang diurutkan, bukan pada hitungan baris.
Syarat tak bisa ditawar adalah urutan yang deterministik. Anda butuh aturan pengurutan yang stabil dan tie-breaker konsisten, kalau tidak kursor tidak dapat diandalkan.
Mulailah dengan memilih satu urutan yang cocok dengan bagaimana orang membaca daftar. Feed, pesan, dan log aktivitas biasanya terbaru di atas. Riwayat seperti faktur dan audit log sering lebih mudah dari yang paling lama ke paling baru.
Kursor harus mengidentifikasi posisi secara unik dalam urutan itu. Jika dua item bisa berbagi nilai kursor yang sama, Anda akan mendapatkan duplikat atau celah.
Pilihan umum dan yang perlu diperhatikan:
created_at: sederhana, tapi berisiko jika banyak baris berbagi timestamp yang sama.id: aman jika ID monoton, tapi mungkin tidak mencerminkan urutan produk yang Anda inginkan.created_at + id: biasanya campuran terbaik (timestamp untuk urutan produk, id sebagai tie-breaker).updated_at sebagai utama: berisiko untuk infinite scroll karena edit bisa memindahkan item antar halaman.Jika Anda menawarkan beberapa opsi sort, perlakukan setiap mode sort sebagai daftar berbeda dengan aturan kursor sendiri. Kursor hanya masuk akal untuk satu urutan yang tepat.
Anda bisa mempertahankan permukaan API kecil: dua input, dua output.
Kirim limit (berapa banyak item yang Anda mau) dan cursor opsional (di mana untuk melanjutkan). Jika kursor hilang, server mengembalikan halaman pertama.
Contoh request:
GET /api/messages?limit=30\u0026cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Kembalikan item dan next_cursor. Jika tidak ada halaman berikutnya, kembalikan next_cursor: null. Klien harus memperlakukan kursor sebagai token, bukan sesuatu yang diedit.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Logika sisi server dalam kata-kata sederhana: urutkan dalam urutan yang stabil, saring menggunakan kursor, lalu terapkan limit.
Jika Anda mengurutkan newest first dengan (created_at DESC, id DESC), dekode kursor menjadi (created_at, id), lalu ambil baris dimana (created_at, id) secara ketat kurang dari pasangan kursor, terapkan urutan yang sama, dan ambil limit baris.
Anda bisa mengenkode kursor sebagai base64 JSON blob (mudah) atau sebagai token yang ditandatangani/dienkripsi (lebih kerja). Opak lebih aman karena memungkinkan Anda mengubah internal nanti tanpa memecahkan klien.
Tetapkan juga default yang masuk akal: default mobile (sering 20-30), default web (sering 50), dan batas maksimal server sehingga satu klien bug tidak bisa meminta 10.000 baris.
Feed yang stabil sebagian besar soal satu janji: setelah pengguna mulai menggulir maju, item yang belum mereka lihat tidak boleh bergoyang karena orang lain membuat, menghapus, atau mengedit record.
Dengan paginasi kursor, insert adalah yang termudah. Record baru harus muncul saat refresh, bukan di tengah halaman yang sudah dimuat. Jika Anda mengurutkan dengan created_at DESC, id DESC, item baru secara alami berada sebelum halaman pertama, sehingga kursor Anda melanjutkan ke item yang lebih lama.
Hapus tidak boleh merombak daftar. Jika item dihapus, item itu tidak akan dikembalikan ketika Anda seharusnya memfetch-nya. Jika Anda perlu menjaga konsistensi ukuran halaman, terus fetch sampai Anda mengumpulkan limit item yang terlihat.
Edit adalah tempat tim secara tidak sengaja memperkenalkan bug lagi. Pertanyaan kuncinya: dapatkah edit mengubah posisi pengurutan?
Perilaku gaya snapshot biasanya terbaik untuk daftar yang digulir: page berdasarkan kunci yang tidak berubah seperti created_at. Edit boleh mengubah konten, tapi item tidak melompat ke posisi baru.
Perilaku live-feed mengurutkan berdasarkan sesuatu seperti edited_at. Itu bisa menyebabkan lonjakan (item lama diedit dan pindah ke atas). Jika Anda memilih ini, anggap daftar sebagai selalu berubah dan rancang UX seputar refresh.
Jangan buat kursor bergantung pada “temukan baris ini persis.” Enkode posisi sebagai nilai, misalnya {created_at, id} dari item terakhir yang dikembalikan. Lalu kueri berikutnya berdasarkan nilai, bukan keberadaan baris:
WHERE (created_at, id) \u003c (:created_at, :id)id) untuk menghindari duplikatPaging maju adalah bagian mudah. Pertanyaan UX yang lebih rumit adalah paging mundur, refresh, dan akses acak.
Untuk paging mundur, dua pendekatan yang sering berhasil:
next_cursor untuk item lebih tua dan prev_cursor untuk item yang lebih baru) sambil mempertahankan satu urutan tampilan di layar.Lompatan acak lebih sulit dengan kursor karena “halaman 20” tidak punya arti stabil saat daftar berubah. Jika Anda benar-benar perlu lompat, lompatlah ke anchor seperti “sekitar timestamp ini” atau “mulai dari id pesan ini,” bukan indeks halaman.
Di mobile, caching penting. Simpan kursor per status daftar (query + filter + sort), dan perlakukan setiap tab/view sebagai daftar sendiri. Itu mencegah perilaku "berpindah tab dan semuanya berantakan".
Kebanyakan masalah paginasi kursor bukan soal database. Mereka muncul dari inkonsistensi kecil antar permintaan yang hanya terlihat di lalu lintas nyata.
Pelaku terbesar:
created_at) sehingga tie menghasilkan duplikat atau item terlewat.next_cursor yang tidak cocok dengan item terakhir yang sebenarnya dikembalikan.Jika Anda membangun aplikasi di platform seperti Koder.ai, edge case ini cepat muncul karena klien web dan mobile sering berbagi endpoint yang sama. Memiliki satu kontrak kursor eksplisit dan satu aturan urutan deterministik menjaga kedua klien konsisten.
Sebelum menyebut paginasi "selesai", verifikasi perilaku di bawah insert, delete, dan retry.
next_cursor diambil dari baris terakhir yang dikembalikanlimit punya max aman dan default yang didokumentasikanUntuk refresh, pilih satu aturan jelas: pengguna tarik untuk refresh untuk mengambil item baru di atas, atau Anda secara periodik memeriksa “ada yang lebih baru dari item pertama saya?” dan tunjukkan tombol “Item baru”. Konsistensi yang membuat daftar terasa stabil alih-alih menyeramkan.
Bayangkan inbox dukungan yang digunakan agen di web, sementara manajer memeriksa inbox yang sama di mobile. Daftar diurutkan dengan terbaru di atas. Orang mengharapkan satu hal: ketika mereka menggulir maju, item tidak meloncat, berulang, atau menghilang.
Dengan paging offset, agen memuat halaman 1 (item 1-20), lalu menggulir ke halaman 2 (offset=20). Saat mereka membaca, dua pesan baru masuk di atas. Sekarang offset=20 menunjuk ke tempat yang berbeda dari sebelumnya. Pengguna melihat duplikat atau melewatkan pesan.
Dengan paginasi kursor, aplikasi meminta “20 item berikutnya setelah kursor ini,” di mana kursor didasarkan pada item terakhir yang benar-benar dilihat pengguna (umumnya (created_at, id)). Pesan baru boleh masuk sepanjang hari, tetapi halaman berikutnya tetap dimulai tepat setelah pesan terakhir yang dilihat pengguna.
Cara sederhana untuk menguji sebelum rilis:
Jika Anda sedang membuat prototipe cepat, Koder.ai bisa membantu Anda membangun endpoint dan alur klien dari prompt chat, lalu iterasi dengan aman menggunakan Planning Mode plus snapshot dan rollback ketika perubahan paginasi mengejutkan Anda saat pengujian.
Paginasi berbasis offset menunjuk ke “lewati N baris”, jadi ketika baris baru disisipkan atau baris lama dihapus, jumlah baris bergeser. Offset yang sama tiba-tiba bisa merujuk ke item yang berbeda dari sebelumnya, yang menghasilkan pengulangan dan celah saat pengguna sedang menggulir.
Paginasi kursor menggunakan penanda posisi yang mewakili “posisi setelah item terakhir yang saya lihat.” Permintaan berikutnya melanjutkan dari posisi itu dalam urutan deterministik, sehingga penyisipan di bagian atas dan penghapusan di tengah tidak memindahkan batas halaman seperti yang dilakukan offset.
Gunakan urutan deterministik dengan tie-breaker, biasanya (created_at, id) dalam arah yang sama. created_at memberi urutan yang sesuai produk, dan id membuat setiap posisi unik sehingga Anda tidak mengulang atau melewatkan item saat timestamp bertabrakan.
Mengurutkan berdasarkan updated_at dapat membuat item meloncat antar halaman saat diedit, yang merusak ekspektasi “scroll yang stabil ke depan”. Jika Anda butuh tampilan "paling baru diedit", rancang UI untuk refresh dan terima adanya reordering alih-alih menjanjikan infinite scroll yang stabil.
Kembalikan token opak sebagai next_cursor dan biarkan klien mengirimkannya kembali tanpa diubah. Pendekatan sederhana adalah mengenkode (created_at, id) item terakhir ke dalam base64 JSON blob, tapi penting untuk memperlakukan nilainya sebagai opak agar Anda bisa mengubah internal nanti.
Bangun kueri berikutnya dari nilai kursor, bukan dari “temukan baris ini persis”. Jika item terakhir dihapus, (created_at, id) yang tersimpan masih menentukan posisi, jadi Anda bisa lanjut aman dengan filter “strictly less than” (atau “greater than”) dalam urutan yang sama.
Gunakan perbandingan strict dan tie-breaker unik, dan selalu ambil next_cursor dari item terakhir yang benar-benar Anda kembalikan. Sebagian besar bug pengulangan berasal dari memakai \u003c= alih-alih \u003c, menghilangkan tie-breaker, atau menghasilkan next_cursor dari baris yang salah.
Pilih satu aturan yang jelas: refresh mengambil item baru di bagian atas, sementara scroll-forward melanjutkan ke item yang lebih tua dari kursor yang ada. Jangan menggabungkan "semantik refresh" ke dalam alur kursor yang sama, atau pengguna akan melihat reordering dan menganggap daftar tidak dapat diandalkan.
Sebuah kursor hanya berlaku untuk satu urutan dan satu set filter yang tepat. Jika klien mengubah mode sort, query pencarian, atau filter, harus memulai sesi paginasi baru tanpa kursor dan menyimpan kursor terpisah per status daftar.
Paginasi kursor bagus untuk penelusuran berurutan tetapi tidak untuk lompatan stabil “halaman 20” karena dataset bisa berubah. Jika butuh lompatan, lompatlah ke anchor seperti “sekitar timestamp ini” atau “mulai setelah id ini”, lalu paginasi dengan kursor dari sana.