Cursor Pagination — metode halaman data yang menggunakan kursor unik untuk navigasi melalui kumpulan catatan yang terurut. Menurut GraphQL Specification (2025), halaman kursor adalah standar yang direkomendasikan untuk API yang bekerja dengan data dinamis. Cursor Pagination menghilangkan kelemahan utama pendekatan Offset: ketidakstabilan saat penyisipan dan penurunan kinerja pada pergeseran besar.
Poin utama
Cursor Pagination (halaman kursor) — metode halaman di mana server mengembalikan bersama dengan data sebuah penunjuk khusus — kursor. Klien menggunakan kursor ini dalam permintaan berikutnya untuk mendapatkan bagian catatan berikutnya. Kursor adalah pengenal unik dari elemen terakhir halaman saat ini.
Berbeda dengan Offset-pagination, di mana klien mengatakan “beri saya halaman 5 dengan 20 catatan”, halaman kursor bekerja berbeda: “beri saya 20 catatan setelah catatan dengan ID = 83”. Server menjalankan kueri dengan kondisi WHERE id > 83 dan LIMIT 20. Pendekatan ini menjamin bahwa setiap catatan masuk tepat ke satu halaman terlepas dari penyisipan.
Konsep halaman kursor telah menyebar luas berkat spesifikasi Relay Connection (GraphQL), yang menjadikan cursor-based pagination standar untuk API modern. Relay mendefinisikan format respons: edges (array catatan dengan kursor), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-pagination bukanlah teknik baru — telah digunakan dalam basis data jauh sebelum munculnya web. Dalam SQL disebut keyset pagination atau seek method. Metode ini menjadi populer di API setelah publikasi spesifikasi Relay pada tahun 2015, yang meresmikan format kursor sebagai string berenkode base64 untuk keseragaman transmisi melalui HTTP.
Prinsip dasar halaman kursor — kueri menggunakan kondisi WHERE pada bidang terindeks untuk memposisikan, bukan pergeseran. Untuk arah maju digunakan WHERE id > last_id, untuk arah mundur — WHERE id < first_id. Indeks B-tree memungkinkan menemukan catatan pertama setelah kursor dalam O(log n), yang memberikan waktu respons yang stabil.
-- Ambil 20 catatan setelah kursor '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Ambil 20 catatan SEBELUM kursor '83' (mundur)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Kursor bisa sederhana (nilai ID) atau kompleks (terdiri dari beberapa bidang). Kursor sederhana adalah kunci utama catatan, misalnya id auto-increment atau UUID. Kursor kompleks digunakan untuk pengurutan berdasarkan bidang tidak unik, misalnya (created_at, id), di mana id menjamin keunikan pada stempel waktu yang identik.
Format API tipikal — kursor dalam bentuk string berenkode base64. Server mendekode kursor, mengekstrak nilai dan membangun kueri SQL. Encoding Base64 menyembunyikan struktur internal kursor dari klien dan memungkinkan mengubah format tanpa kehilangan kompatibilitas mundur. Klien menerima kursor di bidang endCursor respons dan mengirimkannya sebagai string dalam permintaan berikutnya.
Halaman kursor mendukung navigasi dua arah. Untuk gerakan maju (next) digunakan kursor elemen terakhir halaman saat ini, untuk mundur (previous) — kursor elemen pertama. Parameter after dan before dalam permintaan menentukan arah: after mengambil catatan setelah kursor, before — sebelum kursor.
Pilihan antara halaman kursor dan Offset-pagination — salah satu pertanyaan arsitektural kunci saat merancang API. Setiap metode memiliki kelebihan dan kelemahan. Cursor-pagination unggul dalam skenario dengan data dinamis, Offset — dalam skenario dengan navigasi arbitrer.
| Karakteristik | Cursor | Offset |
|---|---|---|
| Stabilitas saat penyisipan | Tinggi (tanpa duplikat) | Rendah (pergeseran halaman) |
| Kinerja pada kumpulan besar | O(log n) — stabil | O(n) — menurun seiring pertumbuhan |
| Navigasi berdasarkan nomor halaman | Tidak | Ya (page=5) |
| Kompleksitas implementasi | Sedang | Rendah |
| Dukungan di REST | cursor/before/after | page/offset |
| Dukungan di GraphQL | Standar Relay | Tidak direkomendasikan |
Offset-pagination melakukan pemindaian tabel penuh hingga posisi OFFSET. Pada offset=100000, basis data membaca dan melewatkan 100000 baris, bahkan jika LIMIT adalah 20. MySQL dan PostgreSQL tidak dapat mengoptimalkan OFFSET — ini adalah fitur implementasi LIMIT/OFFSET di SQL. Cursor-pagination menggunakan indeks B-tree, yang menemukan posisi dalam O(log n).
Masalah tambahan Offset — “melewatkan” catatan saat halaman mundur. Jika pengguna telah memuat halaman 5, dan pada saat itu catatan baru ditambahkan, saat meminta halaman 6 ia akan melihat catatan dari halaman 5 lagi atau melewatkan yang baru. Cursor-pagination sepenuhnya menghilangkan skenario ini: kursor menunjuk ke tempat tertentu dalam kumpulan, dan penyisipan tidak mengubah posisi.
Mari kita lihat implementasi halaman kursor di backend (Kotlin + Spring) dan di klien (Android + Retrofit). Server menerima parameter after, before, limit dan mengembalikan daftar catatan dengan kursor dan pageInfo. Respons tipikal berisi hasNextPage dan hasPreviousPage untuk mengelola UI halaman.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
Di sisi klien, halaman kursor diimplementasikan melalui PagingSource dari Paging 3, di mana kuncinya adalah kursor (Long). PagingSource.load menerima LoadParams.key — kursor catatan terakhir yang dimuat. LoadResult.Page mengembalikan data dan nextKey — kursor untuk halaman berikutnya. Ketika nextKey = null — halaman selesai.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource dengan kunci kursor
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
Dalam GraphQL, halaman kursor diimplementasikan melalui pola Connection Relay. Setiap tipe memiliki Connection (dengan pageInfo dan edges) dan Edge (node + cursor). Permintaan mengirimkan parameter first, after, last, before. Server mengembalikan array edges dengan kursor dan pageInfo dengan hasNextPage/hasPreviousPage.
Cursor-pagination direkomendasikan untuk API yang bekerja dengan data dinamis, di mana catatan sering ditambahkan atau dihapus. Contoh klasik: feed berita di jejaring sosial, pesan chat, riwayat transaksi, komentar pada postingan. Dalam semua skenario ini, konsistensi dan tidak adanya duplikat adalah penting.
Ada skenario di mana Offset-pagination lebih nyaman: panel administrasi di mana navigasi berdasarkan nomor halaman diperlukan; pencarian dengan halaman di mana hasil dapat berubah; laporan dan analitik di mana tautan tetap ke halaman 5 diperlukan. Dalam kasus ini, kelebihan kursor tidak melebihi kompleksitas implementasi.
Halaman kursor tidak mendukung “lompatan” ke halaman arbitrer — pengguna tidak bisa mengklik “Halaman 5” dan pergi ke sana. Ini adalah keterbatasan arsitektural: untuk menghitung jumlah total halaman diperlukan kueri COUNT terpisah, yang bisa mahal untuk tabel besar. Dalam kasus seperti itu, pendekatan hibrida: cursor untuk data + count untuk halaman.
Pertanyaan yang sering diajukan
Kursor adalah pengenal unik catatan yang menunjukkan posisi dalam kumpulan data. Bisa sederhana (ID catatan) atau kompleks (beberapa bidang). Klien menerima kursor catatan terakhir halaman dan mengirimkannya dalam permintaan berikutnya untuk mendapatkan bagian berikutnya.
Cursor-pagination tidak mengalami pergeseran saat penambahan catatan baru — setiap elemen masuk tepat ke satu halaman. Juga mempertahankan kecepatan pada volume besar berkat penggunaan indeks sebagai ganti pemindaian n baris pertama. Offset lebih sederhana tetapi tidak stabil untuk data dinamis.
Ya, halaman kursor tidak terikat pada GraphQL. Dapat diimplementasikan di REST API mana pun dengan mengirimkan kursor sebagai parameter kueri ?after=83&limit=20. Respons harus berisi pageInfo dengan endCursor dan hasNextPage — ini memungkinkan klien mengelola pemuatan tanpa mengetahui struktur internal kursor.
ID auto-increment — pilihan optimal: meningkat secara monoton, tidak berubah, diindeks secara efisien. UUID v7 (terurut waktu) juga cocok. Timestamp dapat memberikan duplikat pada waktu yang sama, oleh karena itu dikombinasikan dengan ID: (created_at, id) untuk menjamin keunikan kursor.
Halaman kursor tidak memberikan jumlah total halaman — ini adalah keterbatasannya. Jika informasi tentang total diperlukan, jalankan kueri COUNT terpisah dengan filter yang sama. Untuk tabel besar, gunakan perhitungan perkiraan melalui EXPLAIN atau total yang di-cache dari analitik.
Ringkasan
Kami akan mengembangkan aplikasi seluler turnkey
IT Sectr membuat aplikasi iOS dan Android untuk startup dan bisnis sejak 2017. Kami akan memberi saran dan mengusulkan solusi terbaik.
Baca juga