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

Masalah: halaman daftar yang berubah saat Anda menggulir
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:
- Item duplikat di beberapa halaman
- Item yang hilang padahal belum pernah ditampilkan
- Item bergeser ke posisi berbeda saat Anda menggulir
- Infinite scroll berhenti lebih awal atau memuat halaman yang sama lagi
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 dalam satu menit
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.
Mengapa offset rusak saat baris disisipkan atau dihapus
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.
Apa arti daftar stabil untuk web dan mobile
"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:
- Stabilitas urutan: ada aturan sort yang jelas (misalnya
created_atdengan tie-breaker sepertiid) sehingga dua permintaan dengan input yang sama mengembalikan urutan yang sama. - Stabilitas paging: setelah pengguna mulai menggulir maju, halaman berikutnya berlanjut dari item terakhir yang mereka lihat, meskipun item baru ditambahkan atau beberapa dihapus.
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: ide sederhana
Paginasi kursor bergerak melalui daftar menggunakan penanda bukmark daripada nomor halaman. Alih-alih “beri saya halaman 3”, klien mengatakan “lanjut dari sini.”
Kontraknya sederhana:
- API mengembalikan sekumpulan item plus sebuah kursor yang merepresentasikan posisi setelah item terakhir.
- Klien mengirim kursor itu kembali untuk mendapatkan batch berikutnya.
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.
Memilih kursor dan urutan pengurutan
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:
- Hanya
created_at: sederhana, tapi berisiko jika banyak baris berbagi timestamp yang sama. - Hanya
id: aman jika ID monoton, tapi mungkin tidak mencerminkan urutan produk yang Anda inginkan. created_at+id: biasanya campuran terbaik (timestamp untuk urutan produk,idsebagai tie-breaker).updated_atsebagai 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.
Langkah demi langkah: bentuk API kursor yang bersih
Anda bisa mempertahankan permukaan API kecil: dua input, dua output.
1) Request: limit + cursor
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==
2) Response: items + next_cursor
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.
3) Pengodean kursor: opak lebih baik
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.
Menangani insert, delete, dan edit
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?
Pilih perilaku snapshot atau live
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.
Saat item kursor tidak lagi ada
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:
- Untuk urutan menurun:
WHERE (created_at, id) \u003c (:created_at, :id) - Selalu sertakan tie-breaker (
id) untuk menghindari duplikat - Jika item terakhir dihapus, nilainya masih bekerja
- Jika item terakhir diedit, ia tetap bekerja selama kunci urutan Anda tidak berubah
Paging mundur, refresh, dan lompat-lompat
Paging maju adalah bagian mudah. Pertanyaan UX yang lebih rumit adalah paging mundur, refresh, dan akses acak.
Untuk paging mundur, dua pendekatan yang sering berhasil:
- Kembalikan kedua arah (
next_cursoruntuk item lebih tua danprev_cursoruntuk item yang lebih baru) sambil mempertahankan satu urutan tampilan di layar. - Simpan satu kursor, tapi mintalah sort terbalik ketika pengguna menggulir ke atas.
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".
Kesalahan umum yang menciptakan bug misterius
Kebanyakan masalah paginasi kursor bukan soal database. Mereka muncul dari inkonsistensi kecil antar permintaan yang hanya terlihat di lalu lintas nyata.
Pelaku terbesar:
- Menggunakan kursor yang tidak unik (misalnya hanya
created_at) sehingga tie menghasilkan duplikat atau item terlewat. - Mengembalikan
next_cursoryang tidak cocok dengan item terakhir yang sebenarnya dikembalikan. - Mengubah filter atau urutan antara permintaan halaman.
- Mencampur offset dan kursor di endpoint yang sama.
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.
Daftar periksa cepat sebelum meluncurkan
Sebelum menyebut paginasi "selesai", verifikasi perilaku di bawah insert, delete, dan retry.
- Urutan eksplisit, deterministik, dan menyertakan tie-breaker
- Setiap permintaan mengulangi filter dan field sort yang identik
next_cursordiambil dari baris terakhir yang dikembalikanlimitpunya max aman dan default yang didokumentasikan- Perilaku refresh didefinisikan (bagaimana item baru muncul)
Untuk 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.
Contoh realistis: inbox yang tetap stabil di seluruh perangkat
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:
- Mulai mengambil halaman sementara skrip menyisipkan pesan baru setiap detik
- Hapus beberapa pesan di tengah scroll
- Edit sebuah pesan (tanpa mengubah field pengurutan)
- Pastikan Anda tidak pernah mendapatkan duplikat, celah, atau item urutan salah
- Verifikasi aplikasi mobile dan web menunjukkan batas halaman yang konsisten
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.
Pertanyaan umum
Why do I get duplicates or missing items when I paginate with offset?
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.
How does cursor pagination prevent the “list changed under me” problem?
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.
What should I use as a cursor: created_at, id, or both?
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.
Can I paginate by updated_at for a live feed?
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.
What should my API response include for cursor pagination?
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.
What happens if the item referenced by the cursor gets deleted?
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.
How do I avoid repeats when fetching the next page?
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.
How should refresh work with cursor pagination?
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.
Can I reuse the same cursor if the user changes filters or sort order?
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.
How do I support jumping to a specific page or random access with cursors?
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.