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.”
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;
Kueri tersebut mengembalikan halaman 3 dari daftar pesanan dengan 25 baris per halaman:
GET /v1/orders?page=3&per_page=25
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
}
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 0membaca 25 entri indeks. Hanya beberapa milidetik. -
LIMIT 25 OFFSET 100000membaca 100.025 entri dan membuang 100.000. Puluhan milidetik. -
LIMIT 25 OFFSET 1500000membaca 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;
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
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:
- Tidak ada baris yang terlewat meskipun terjadi penulisan bersamaan.
- 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"
}
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: nullpada 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 respons200kosong. 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
-
Panggil endpoint dan ekstrak kursor. Tambahkan post-processor pada permintaan pertama dengan JSONPath
$.next_cursor, lalu simpan nilainya dalam variabel sepertinextCursor. Apidog memungkinkan Anda menyalin JSONPath langsung dari panel respons. Lihat panduan mengatur assertion dan mengekstrak variabel dengan JSONPath. -
Ulangi permintaan halaman berikutnya. Bungkus permintaan kedua dalam langkah
ForEachatau loop. Teruskan{{nextCursor}}sebagai parameter kursor, ekstrak ulang$.next_cursorpada setiap iterasi, lalu berhenti saathas_morebernilaifalse.
Tambahkan assertion untuk memastikan:
- Tidak ada
idyang 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
datasama denganper_pagehingga halaman terakhir. - Pastikan nilai
totaltetap 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
dataadalah[],has_morebernilaifalse, dan statusnya200. -
Kursor tidak valid: kirim
cursor=not-a-real-cursor. Pastikan statusnya400dan 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) < (?, ?)
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)