DEV Community

Cover image for Paginasi Berbasis Kursor vs Paginasi Offset: Mana yang Sebaiknya Digunakan API Anda
Walse
Walse

Posted on Originally published at apidog.com

Paginasi Berbasis Kursor vs Paginasi Offset: Mana yang Sebaiknya Digunakan API Anda

Setiap endpoint daftar pada akhirnya menghadapi pertanyaan yang sama: bagaimana Anda membagi 2 juta pesanan menjadi halaman yang dapat dijelajahi klien? Pilih pagination offset dan Anda mendapatkan SQL sederhana serta nomor halaman yang mudah dipahami. Pilih pagination berbasis kursor dan Anda mendapatkan hasil stabil serta latensi konsisten di kedalaman mana pun—tetapi kehilangan fitur “loncat ke halaman 47.”

Coba Apidog hari ini

Sebagian besar tim memilih offset karena menjadi pengaturan default di banyak tutorial. Masalah muncul ketika tabel pesanan mencapai jutaan baris: halaman 4.000 mulai timeout, dan pengguna melihat catatan yang sama dua kali saat menggulir.

Panduan ini menjelaskan cara kerja kedua gaya pagination, kapan offset gagal, mengapa Stripe dan Slack menggunakan kursor, serta cara menguji keduanya dengan permintaan berantai di Apidog. Jika membutuhkan gambaran umum, baca juga panduan pagination API.

Bagaimana pagination offset bekerja

Pagination offset memetakan langsung ke SQL. Klien mengirimkan nomor halaman dan ukuran halaman, lalu server menerjemahkannya menjadi LIMIT dan OFFSET.

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Enter fullscreen mode Exit fullscreen mode

Kueri tersebut mengembalikan halaman 3 dari daftar pesanan dengan 25 baris per halaman:

GET /v1/orders?page=3&per_page=25
Enter fullscreen mode Exit fullscreen mode

Contoh respons:

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}
Enter fullscreen mode Exit fullscreen mode

Daya tariknya jelas:

  • Klien dapat melompat ke halaman mana pun.
  • Server dapat mengembalikan jumlah total.
  • Implementasinya cepat.
  • Cocok untuk tabel admin kecil.

Untuk implementasi lengkap, lihat panduan pagination di REST API.

Namun, offset memiliki dua masalah struktural yang biasanya baru terlihat di produksi.

Masalah 1: pergeseran halaman (page drift)

Offset menghitung baris dari bagian atas hasil yang diurutkan. Offset tidak mengetahui baris mana yang sudah dilihat klien. Jika baris ditambahkan atau dihapus di antara dua permintaan, halaman di bawahnya bergeser.

Misalnya, pengguna memuat halaman 1 dari pesanan terbaru, yaitu baris 1–25. Saat mereka membaca, 3 pesanan baru masuk. Mereka kemudian meminta halaman 2 dengan OFFSET 25.

Baris 23, 24, dan 25 dari respons pertama kini bergeser ke posisi 26–28. Pengguna melihatnya lagi—terjadi duplikat.

Penghapusan menghasilkan masalah sebaliknya. Jika 3 baris dari halaman 1 dihapus saat pengguna membaca, OFFSET 25 akan melewati 3 baris yang belum pernah mereka lihat. Data hilang secara diam-diam tanpa menghasilkan error.

Untuk laporan bulanan yang tidak dibaca secara real-time, page drift mungkin tidak masalah. Namun untuk umpan aktivitas, endpoint sinkronisasi, atau skrip yang menelusuri halaman saat data terus berubah, hasilnya adalah catatan duplikat atau hilang.

Masalah 2: offset dalam memindai semua baris yang dilewati

OFFSET 500000 tidak langsung melompat ke baris 500.001. Database harus menelusuri indeks melalui 500 ribu entri, membuangnya, lalu mengembalikan 25 baris yang diminta. Biayanya tumbuh linear terhadap kedalaman: O(n), dengan n sebagai nilai offset.

Contoh pada tabel orders PostgreSQL berisi 2 juta baris dengan indeks pada created_at:

  • LIMIT 25 OFFSET 0 membaca 25 entri indeks. Hanya beberapa milidetik.
  • LIMIT 25 OFFSET 100000 membaca 100.025 entri dan membuang 100.000. Puluhan milidetik.
  • LIMIT 25 OFFSET 1500000 membaca 1,5 juta entri. Waktunya dapat mencapai ratusan milidetik, sekaligus menahan buffer dan membakar CPU.

Tulisan tanpa offset oleh Markus Winand menjelaskan biaya ini melalui rencana kueri.

Dalam produksi, pola yang sering muncul adalah log kueri lambat yang didominasi permintaan dengan offset tinggi—sering kali berasal dari satu perayap yang menjelajahi setiap halaman API publik. Satu klien saja dapat menggandakan p99 Anda.

Bagaimana pagination berbasis kursor bekerja

Pagination berbasis kursor, atau pagination keyset, menghilangkan penghitung baris. Alih-alih berkata “lewati 50 baris”, klien berkata “berikan baris setelah catatan ini”.

Kursor mengidentifikasi baris terakhir yang dilihat klien sehingga server dapat langsung mencari batch berikutnya.

SQL menggunakan perbandingan baris pada kunci pengurutan, bukan OFFSET:

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Enter fullscreen mode Exit fullscreen mode

Perhatikan perbandingan dua kolom. created_at saja tidak unik; dua pesanan dapat dibuat pada waktu yang sama. Kunci pengurutan yang tidak unik dapat menyebabkan baris dilewati atau diulang di batas halaman.

Menambahkan id sebagai pemecah seri (tiebreaker) membuat urutan menjadi total dan pagination menjadi tepat. Dengan indeks komposit pada (created_at, id), database dapat langsung mencari posisi batas dan membaca 25 entri. Halaman 1 dan halaman 60.000 memiliki biaya yang hampir sama.

Gunakan kursor opak

API sebaiknya tidak mengekspos nilai mentah tersebut. Encode kunci pengurutan menjadi token opak, biasanya menggunakan Base64:

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Enter fullscreen mode Exit fullscreen mode

Opasitas adalah keputusan desain, bukan sekadar pengaburan. Jika klien tidak dapat mengurai kursor, Anda bebas mengubah kunci pengurutan, menambahkan petunjuk shard, atau mengganti mesin penyimpanan tanpa merusak konsumen.

Kontraknya sederhana: “kembalikan apa yang kami berikan kepada Anda.”

Kekurangannya adalah tidak ada halaman 47. Kursor hanya mengetahui posisi “setelah baris ini”, sehingga klien bergerak maju—atau mundur jika Anda menyediakan kursor sebelumnya—satu halaman pada satu waktu.

Jumlah total juga tidak tersedia secara gratis karena memerlukan kueri terpisah. Untuk pembahasan skalabilitas pada dataset besar, baca mendesain pagination API untuk jutaan catatan.

Perbandingan singkat

Dimensi Pagination offset Pagination berbasis kursor
Loncat ke halaman acak Ya, nomor halaman berapa pun Tidak, hanya penelusuran berurutan
Jumlah total / jumlah halaman Murah untuk disertakan Memerlukan kueri hitung terpisah
Kinerja halaman dalam O(n), menurun seiring kedalaman O(1) per halaman di kedalaman mana pun
Stabilitas saat ada penulisan Bergeser: dapat menghasilkan duplikat dan celah Stabil, tertambat pada baris
Biaya pembangunan Sangat mudah Sedang: encoding, tiebreaker, dan desain indeks
Persyaratan pengurutan ORDER BY apa pun Kunci urut unik dan terindeks
Cache URL halaman Mudah, URL dapat diprediksi Lebih sulit, kursor berbeda pada tiap penelusuran
Kompleksitas klien Rendah Rendah jika amplop respons bersih

Satu hal penting: pagination kursor membutuhkan pengurutan deterministik. Jika endpoint memungkinkan pengurutan berdasarkan kolom yang dapat berubah dan tidak unik seperti status, logika keyset akan cepat menjadi rumit.

Offset lebih toleran terhadap pengurutan yang kurang rapi; kursor tidak.

Mana yang harus dipilih?

Sesuaikan gaya pagination dengan cara data dikonsumsi.

Tabel admin dan dasbor: offset

Gunakan offset untuk alat internal dengan beberapa ribu baris, ketika manusia mengklik nomor halaman dan membutuhkan hitungan seperti “1.848 hasil”.

Pergeseran biasanya tidak menjadi masalah, kedalaman tetap dangkal, dan fitur loncat halaman memang berguna. Offset juga unggul dari sisi biaya pembangunan.

Umpan infinite scroll: kursor

Tidak ada pengguna yang perlu melompat ke halaman 47 dari sebuah umpan. Mereka hanya memuat lebih banyak data, sementara penulisan dapat terjadi terus-menerus.

Duplikat terlihat jelas dan merusak pengalaman pengguna. Ini adalah kasus yang sangat cocok untuk kursor.

API publik: kursor

Anda tidak mengontrol konsumen API. Seseorang pasti akan membuat loop yang menelusuri setiap halaman. Dengan offset, halaman dalam menjadi masalah performa Anda.

Kursor membuat setiap halaman murah dan memungkinkan Anda mengubah implementasi internal di balik token opak. Panduan pagination REST API membahas konvensi URL dan header secara lebih detail.

Ekspor dan pekerjaan sinkronisasi: kursor

Pekerjaan batch yang mengambil 2 juta pesanan membutuhkan dua jaminan:

  1. Tidak ada baris yang terlewat meskipun terjadi penulisan bersamaan.
  2. Biaya setiap halaman tetap konsisten.

Offset tidak menyediakan keduanya. Kursor juga memberi titik melanjutkan gratis jika pekerjaan berhenti pada baris 1,4 juta.

Aturan praktis:

  • Gunakan offset untuk antarmuka kecil yang dijelajahi manusia dan membutuhkan banyak hitungan.
  • Gunakan kursor untuk data besar, real-time, atau API publik.

Bagaimana API nyata menanganinya

Stripe sepenuhnya berbasis kursor. Setiap endpoint daftar menerima starting_after dan limit, lalu respons menyertakan has_more. Untuk mengambil halaman biaya berikutnya, teruskan ID biaya terakhir yang diterima. Lihat dokumentasi pagination Stripe.

Tidak ada jumlah total di respons Stripe. Ini adalah pilihan yang disengaja untuk API dengan volume penulisan tinggi.

GitHub REST API masih mengekspos page dan per_page pada sebagian besar endpoint, dengan header Link yang menunjuk ke halaman berikutnya dan terakhir. Namun, dokumentasi pagination GitHub menginstruksikan klien untuk mengikuti header Link secara harfiah, bukan membuat URL halaman sendiri.

Beberapa endpoint GitHub yang lebih baru telah beralih ke kursor karena penelusuran offset dalam pada repositori besar dapat menjadi mahal.

Slack memigrasikan Web API-nya ke pagination berbasis kursor dan kini menjadikannya pendekatan untuk metode baru. Metode seperti conversations.history mengembalikan response_metadata.next_cursor. String kursor kosong menandakan akhir data, seperti dijelaskan dalam dokumentasi pagination Slack.

Tiga API dengan lalu lintas tinggi tersebut menunjukkan arah yang sama: menuju kursor.

Mendesain amplop respons

API berbasis kursor sangat bergantung pada amplop respons yang sederhana dan dapat diprediksi:

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Enter fullscreen mode Exit fullscreen mode

Ikuti empat aturan berikut:

  • Selalu kembalikan has_more. Klien tidak boleh menyimpulkan akhir hanya dari halaman yang lebih pendek; halaman tengah dapat menjadi pendek setelah proses filter.
  • Kembalikan next_cursor: null pada halaman terakhir dan dokumentasikan perilakunya. String kosong seperti Slack juga bisa digunakan, tetapi pilih satu konvensi dan konsisten.
  • Tolak kursor tidak valid dengan status 400, bukan respons 200 kosong. Kursor rusak adalah bug klien dan harus terlihat saat debugging.
  • Tandatangani atau versikan payload kursor jika berisi informasi selain kunci pengurutan. Ini akan membantu saat migrasi skema.

Menguji kedua gaya di Apidog

Bug pagination biasanya tersembunyi di batas-batas berikut:

  • Halaman terakhir.
  • Halaman kosong.
  • Kursor yang baris penambatnya sudah dihapus.

Klik manual tidak cukup untuk menangkapnya. Gunakan skenario pengujian berantai di Apidog.

Menguji endpoint kursor

  1. Panggil endpoint dan ekstrak kursor. Tambahkan post-processor pada permintaan pertama dengan JSONPath $.next_cursor, lalu simpan nilainya dalam variabel seperti nextCursor. Apidog memungkinkan Anda menyalin JSONPath langsung dari panel respons. Lihat panduan mengatur assertion dan mengekstrak variabel dengan JSONPath.
  2. Ulangi permintaan halaman berikutnya. Bungkus permintaan kedua dalam langkah ForEach atau loop. Teruskan {{nextCursor}} sebagai parameter kursor, ekstrak ulang $.next_cursor pada setiap iterasi, lalu berhenti saat has_more bernilai false.

Tambahkan assertion untuk memastikan:

  • Tidak ada id yang berulang dari halaman sebelumnya.
  • Ukuran halaman tidak pernah melebihi limit.
  • Penelusuran berhenti pada kondisi yang benar.

Menguji endpoint offset

Gunakan struktur serupa dengan variabel penghitung:

  • Tambahkan page.
  • Pastikan panjang data sama dengan per_page hingga halaman terakhir.
  • Pastikan nilai total tetap konsisten selama penelusuran.

Uji kasus tepi

Jadikan setiap kasus berikut sebagai langkah pengujian tersendiri dengan assertion eksplisit:

  • Halaman kosong: gunakan filter yang cocok dengan nol baris. Pastikan data adalah [], has_more bernilai false, dan statusnya 200.
  • Kursor tidak valid: kirim cursor=not-a-real-cursor. Pastikan statusnya 400 dan respons berisi kode kesalahan yang dapat dibaca mesin.
  • Baris penambat terhapus: buat pesanan, ambil kursor yang tertambat padanya, hapus pesanan, lalu gunakan kursor tersebut. Pastikan penelusuran berlanjut dari posisi yang benar tanpa error.

Perbandingan keyset menangani penghapusan baris penambat secara alami. Assertion ini memastikan perilakunya tetap terjaga.

Setelah skenario lulus secara lokal, jalankan di CI pada setiap penggabungan. Anda dapat mengunduh Apidog secara gratis dan membuat skenario penelusuran kursor lengkap dengan loop serta assertion dalam waktu kurang dari setengah jam.

FAQ

Apakah pagination kursor selalu lebih baik?

Tidak.

Offset lebih cocok ketika pengguna membutuhkan nomor halaman, jumlah total, dan akses acak pada dataset sederhana—seperti kebanyakan alat admin internal.

Kursor lebih baik ketika dataset besar, penulisan sering terjadi, atau API bersifat publik. Kesalahan yang perlu dihindari adalah menjadikan offset sebagai default untuk endpoint daftar publik, lalu baru menemukan biaya O(n) setelah API diluncurkan.

Bagaimana cara mendapatkan jumlah total dengan pagination kursor?

Jalankan SELECT COUNT(*) terpisah dengan filter yang sama. Anda dapat menyediakannya melalui endpoint berbeda atau parameter opt-in seperti include_count=true.

Lakukan caching secara agresif. Jumlah perkiraan yang diperbarui setiap menit biasanya sudah cukup untuk hampir semua UI. Stripe bahkan tidak mengembalikan total, yang menunjukkan bahwa klien sebenarnya tidak selalu membutuhkannya.

Bisakah saya menawarkan kedua gaya pagination pada satu endpoint?

Bisa. GitHub secara efektif melakukan hal ini selama masa transisi, tetapi sebaiknya hindari pola tersebut pada API baru.

Dua gaya berarti:

  • Dua set kasus tepi.
  • Dua matriks pengujian.
  • Kebingungan klien tentang gaya yang harus digunakan.

Pilih satu gaya per endpoint. Jika merancang kontrak dari awal, gunakan pola dalam panduan pagination REST API agar penamaan parameter tetap konsisten.

Apa yang terjadi jika baris penambat kursor dihapus?

Dengan pagination keyset, tidak ada yang rusak. Perbandingan berikut tidak mengharuskan baris penambat masih ada:

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

Database cukup mencari posisi batas dan melanjutkan penelusuran. Ini adalah keunggulan nyata dibandingkan desain “kursor sebagai pencarian baris”, sekaligus kasus tepi yang perlu diuji di Apidog sebelum ditemukan oleh konsumen API.

Top comments (0)