Offset vs cursor pagination bukan sekadar pilihan gaya API. Saat data di tabel SQL terus bertambah, OFFSET/LIMIT bisa menjadi sumber latency yang makin sulit dikendalikan, terutama pada halaman-halaman besar, query dengan ORDER BY, dan data yang aktif berubah. Jika kebutuhan Anda adalah daftar data yang stabil, cepat, dan aman dari duplikasi atau data lompat, cursor pagination sering menjadi desain yang lebih tahan skala.
Intinya: OFFSET/LIMIT cocok untuk navigasi sederhana dan dataset kecil hingga menengah, terutama bila pengguna benar-benar butuh “lompat ke halaman 27”. Cursor pagination lebih cocok ketika data tumbuh besar, urutan data penting, dan hasil query harus konsisten di bawah beban tulis. Jangan menebak perilaku database seolah-olah query planner “pintar seperti manusia”; ukur perilaku nyata lewat EXPLAIN, latency aktual, dan pola akses dari aplikasi.
Kapan OFFSET/LIMIT mulai lambat?
Masalah utama offset bukan pada LIMIT-nya, melainkan pada OFFSET-nya. Untuk mengambil halaman dengan offset besar, database umumnya tetap perlu melewati sejumlah baris terlebih dahulu sebelum mengembalikan hasil. Makin jauh halaman yang diminta, makin banyak kerja yang perlu dilakukan.
Contoh query offset
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Secara logis, query ini meminta 20 baris. Tetapi untuk sampai ke sana, database sering harus menelusuri atau menyortir jauh lebih banyak dari 20 baris. Ini menjadi mahal ketika:
- Tabel sudah besar.
- Kondisi
WHEREtidak didukung index yang tepat. ORDER BYmemicu sort tambahan.- Offset mencapai ribuan atau jutaan.
- Query dipanggil sering, misalnya pada feed, admin list, atau API publik.
Tanda-tanda offset mulai bermasalah
- Latency halaman awal masih bagus, tetapi halaman dalam jauh lebih lambat.
- Penggunaan CPU dan I/O database naik saat trafik pembacaan meningkat.
EXPLAINmenunjukkan scan baris jauh lebih banyak daripada hasil yang dikembalikan.- Query menjadi tidak stabil ketika ada penulisan data bersamaan.
Jangan menetapkan ambang seperti “offset di atas angka tertentu pasti buruk”. Angka amannya bergantung pada ukuran tabel, selektivitas filter, kualitas index, pola cache, dan jenis storage. Ukur di sistem Anda sendiri.
Mengapa cursor pagination lebih stabil saat data tumbuh?
Cursor pagination tidak meminta database “lewati 10.000 baris dulu”. Sebaliknya, ia berkata: beri saya 20 baris setelah posisi terakhir yang sudah saya lihat. Posisi ini biasanya diwakili oleh nilai kolom pengurutan, misalnya created_at dan id.
Contoh query cursor
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
AND (created_at, id) < ('2025-01-15 10:30:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 20;Pendekatan ini biasanya lebih efisien karena database bisa langsung melanjutkan dari posisi tertentu pada index, bukan menghitung offset dari awal. Dengan desain index yang benar, jumlah baris yang disentuh lebih dekat ke jumlah baris yang benar-benar diambil.
Kenapa perlu dua kolom: created_at dan id?
Mengurutkan hanya berdasarkan created_at sering tidak cukup stabil karena banyak baris bisa memiliki timestamp yang sama. Akibatnya, urutan bisa ambigu. Menambahkan id sebagai tie-breaker membuat urutan deterministik.
Misalnya, urutan yang stabil:
ORDER BY created_at DESC, id DESCMaka cursor juga harus mengikuti urutan yang sama:
AND (created_at, id) < (?, ?)Jika database atau ORM Anda tidak nyaman dengan perbandingan tuple, bentuk yang ekuivalen adalah:
WHERE status = 'published'
AND (
created_at < ?
OR (created_at = ? AND id < ?)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;Bottleneck SQL umum pada pagination
1. ORDER BY tidak didukung index yang sesuai
Query pagination hampir selalu bergantung pada urutan. Jika WHERE dan ORDER BY tidak selaras dengan index, database mungkin perlu melakukan sort tambahan atau scan lebih luas.
Contoh pola query:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;Index yang sering lebih tepat untuk pola ini:
CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at DESC, id DESC);Intinya bukan sekadar “pasang index”, tetapi pasang index yang mengikuti filter dan urutan akses.
2. Mengambil kolom terlalu banyak
Pagination daftar biasanya tidak perlu seluruh kolom. Mengambil kolom besar seperti body artikel, JSON besar, atau blob akan menambah I/O dan memperlambat query.
Lebih baik:
SELECT id, created_at, title, author_id
FROM posts
...Daripada:
SELECT *
FROM posts
...3. COUNT(*) pada setiap request
Offset pagination sering diikuti kebutuhan menampilkan total halaman. Ini memicu COUNT(*) yang bisa mahal pada tabel besar atau filter kompleks. Untuk banyak use case API, total count tidak selalu wajib pada setiap request.
Pilihan praktis:
- Hitung total secara terpisah dan cache hasilnya bila cukup aman.
- Tampilkan has_next_page alih-alih total halaman.
- Gunakan estimasi jika kebutuhan bisnis mengizinkan.
4. Join berat sebelum pagination
Jika query melakukan join besar lalu baru dipaginasi, biaya bisa membengkak. Sering lebih efisien mengambil ID terlebih dahulu dengan index yang baik, lalu mengambil detail data pada langkah berikutnya.
Masalah page drift, duplikasi, dan skip data
Offset pagination rentan terhadap perubahan data di antara dua request. Inilah salah satu masalah terbesar pada aplikasi nyata.
Contoh page drift
- Pengguna membuka halaman 1: item A, B, C, D, E.
- Di saat yang sama, item baru X dan Y masuk ke posisi paling atas.
- Pengguna membuka halaman 2 dengan
OFFSET 5.
Karena daftar bergeser, halaman 2 bisa berisi item yang sebelumnya sudah terlihat, atau justru melewatkan beberapa item. Ini menghasilkan:
- Duplikasi: item muncul lagi di halaman berikutnya.
- Skip data: item tertentu tidak pernah terlihat pengguna.
Cursor pagination lebih tahan terhadap masalah ini karena ia melanjutkan dari posisi terakhir yang benar-benar sudah diterima klien, bukan dari nomor halaman yang diasumsikan tetap.
Cursor pagination tidak berarti “snapshot sempurna”. Jika data berubah atau item dihapus, hasil tetap bisa berubah. Namun untuk feed yang aktif, ia biasanya lebih konsisten daripada offset.
Desain index untuk ORDER BY + WHERE
Prinsip pentingnya: urutkan index sesuai pola filter dan urutan query yang paling sering dipakai. Jangan membuat index hanya berdasarkan kolom populer tanpa melihat bagaimana query dibangun.
Contoh skema dan query
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
title VARCHAR(255) NOT NULL,
author_id BIGINT NOT NULL
);Query umum:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;Index yang relevan:
CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at DESC, id DESC);Kapan index komposit membantu?
- Ada filter tetap seperti
status = 'published'. - Urutan hasil konsisten, misalnya
created_at DESC, id DESC. - Query sering dipanggil dan menjadi jalur panas aplikasi.
Kesalahan umum saat membuat index
- Menganggap satu index tunggal per kolom cukup untuk semua query.
- Tidak menambahkan tie-breaker seperti
idpada urutan. - Membuat terlalu banyak index hingga beban tulis meningkat.
- Tidak mengecek apakah query benar-benar memakai index yang diharapkan lewat
EXPLAIN.
EXPLAIN, latency, dan pola akses: ukur, jangan berasumsi
Belakangan ramai klaim sistem yang terdengar “mirip manusia”. Untuk database, pendekatan seperti itu justru menyesatkan. Jangan menganggap planner “mengerti niat Anda”. Perlakukan database sebagai sistem yang harus diukur perilakunya.
Apa yang perlu dilihat saat diagnosis?
- EXPLAIN: apakah query memakai index yang tepat, scan terlalu banyak baris, atau melakukan sort mahal?
- Latency per halaman: bandingkan halaman awal dan halaman dalam.
- P95/P99: rata-rata sering menutupi lonjakan buruk.
- Pola akses: apakah pengguna benar-benar membuka halaman ke-500, atau kebanyakan hanya scroll maju?
- Rasio read/write: semakin aktif datanya berubah, semakin besar risiko page drift pada offset.
Contoh pendekatan pengukuran
- Ambil query halaman 1, halaman 10, halaman 100, dan halaman dalam lain yang realistis.
- Jalankan
EXPLAINpada masing-masing query. - Catat latency pada kondisi produksi atau staging yang mendekati produksi.
- Periksa apakah jumlah baris yang diproses naik tajam saat offset membesar.
- Bandingkan dengan query cursor yang memakai index sama.
Fokus pada perilaku aktual, bukan asumsi “harusnya planner tahu cara tercepat”.
Kapan tetap memilih offset pagination?
Offset bukan anti-pola. Ia masih berguna bila kebutuhan Anda memang cocok.
Offset cocok ketika:
- Dataset kecil atau masih cukup terkontrol.
- Pengguna butuh nomor halaman yang jelas.
- Akses acak ke halaman tertentu penting, misalnya laporan atau admin backoffice.
- Data relatif statis selama sesi baca.
- Biaya
COUNT(*)dan offset masih dapat diterima.
Cursor lebih cocok ketika:
- Data bertambah terus dan daftar diurutkan kronologis.
- Pengguna cenderung scroll maju, bukan lompat ke halaman acak.
- Feed sering berubah karena insert/delete/update.
- Offset besar mulai memicu latency yang tidak stabil.
- Anda ingin mengurangi risiko duplikasi dan skip data.
Trade-off UX: nomor halaman vs aliran data stabil
Pemilihan pagination bukan hanya soal SQL, tetapi juga pengalaman pengguna.
Kelebihan UX offset
- Mudah dipahami: halaman 1, 2, 3, dan seterusnya.
- Mudah untuk “lompat ke halaman tertentu”.
- Sering cocok untuk tabel admin, arsip, atau katalog statis.
Kelebihan UX cursor
- Lebih natural untuk infinite scroll, feed, timeline, dan API mobile.
- Lebih stabil di bawah perubahan data.
- Biasanya performanya lebih konsisten pada dataset besar.
Kompromi yang sering dipakai
Banyak sistem memakai offset di area admin internal dan cursor di endpoint publik atau feed utama. Ini pilihan yang praktis karena kebutuhan UX dan beban teknisnya memang berbeda.
Desain API cursor yang stabil
Cursor sebaiknya dianggap sebagai token posisi, bukan detail implementasi yang klien susun sendiri. Meski secara internal cursor bisa berisi created_at dan id, API publik lebih aman jika mengemasnya dalam token yang opak.
Contoh respons API
{
"data": [
{ "id": 987654, "title": "...", "created_at": "2025-01-15T10:30:00Z" }
],
"paging": {
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xNVQxMDozMDowMFoiLCJpZCI6OTg3NjU0fQ==",
"has_next_page": true
}
}Prinsip desain API yang baik
- Cursor opak: klien tidak perlu tahu struktur internal.
- Urutan deterministik: selalu gunakan kombinasi sort yang unik secara praktis, misalnya
created_at+id. - Konsistensi arah: jelas antara next dan previous.
- Validasi input: cursor rusak atau kadaluarsa harus ditangani dengan error yang jelas.
- Dokumentasi filter: jika filter berubah, cursor lama bisa tidak valid atau menghasilkan urutan berbeda.
Jangan campur logika yang tidak konsisten
Jika halaman pertama memakai ORDER BY created_at DESC, id DESC, halaman berikutnya harus mengikuti urutan yang sama persis. Mengubah ORDER BY di tengah jalan akan merusak konsistensi cursor.
Langkah migrasi bertahap dari offset ke cursor
Migrasi tidak harus sekali jalan. Cara paling aman adalah menjalankan keduanya sementara waktu.
Strategi migrasi yang praktis
- Identifikasi endpoint panas
Cari endpoint dengan offset besar, latency tinggi, atau tingkat page drift yang sering dikeluhkan. - Tetapkan urutan stabil
Pilih kolom sort utama dan tie-breaker, misalnyacreated_at DESC, id DESC. - Tambahkan index komposit
PastikanWHEREdanORDER BYdidukung index yang sesuai. - Buat endpoint cursor paralel
Misalnya pertahankan?page=untuk kompatibilitas, lalu tambahkan?cursor=untuk klien baru. - Instrumentasi dan bandingkan
Pantau latency, error rate, dan perilaku konsumsi API. - Perbarui klien bertahap
Mobile app, web app, dan integrasi pihak ketiga sering perlu jadwal migrasi berbeda. - Kurangi ketergantungan pada total count
Ganti denganhas_next_pagebila memungkinkan. - Depresiasi offset untuk jalur tertentu
Misalnya feed publik pindah ke cursor, sementara admin tetap memakai offset.
Contoh transisi query
Sebelum:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 2000;Sesudah:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;Checklist diagnosis query pagination lambat di produksi
- Apakah query lambat hanya pada offset besar, atau sejak halaman awal?
- Apakah
WHEREdanORDER BYdidukung oleh index komposit yang relevan? - Apakah urutan hasil deterministik, termasuk tie-breaker unik?
- Apakah query mengambil terlalu banyak kolom?
- Apakah ada
COUNT(*)mahal pada setiap request? - Apakah join dilakukan sebelum pagination padahal bisa dipisah?
- Apakah data sering berubah sehingga offset memicu duplikasi/skip?
- Apakah
EXPLAINmenunjukkan sort, scan, atau row examined yang tinggi? - Apakah P95/P99 latency memburuk jauh dibanding rata-rata?
- Apakah use case benar-benar membutuhkan nomor halaman, atau cukup next/previous?
- Apakah cursor yang dikirim API stabil terhadap perubahan filter dan urutan?
- Apakah ada observabilitas untuk membandingkan performa offset vs cursor di trafik nyata?
Kesimpulan
OFFSET/LIMIT mulai menjadi masalah ketika data membengkak, offset membesar, dan daftar sering berubah. Gejalanya biasanya terlihat pada latency yang meningkat per halaman, scan baris yang berlebihan, dan inkonsistensi hasil seperti duplikasi atau skip data. Cursor pagination menjadi pilihan yang lebih kuat untuk feed dan API dengan pertumbuhan data nyata karena bekerja berdasarkan posisi terakhir, bukan nomor halaman statis.
Namun, keputusan terbaik tetap bergantung pada kebutuhan produk. Jika pengguna perlu nomor halaman dan dataset relatif stabil, offset masih masuk akal. Jika prioritas Anda adalah performa yang lebih konsisten dan hasil yang lebih stabil saat data terus bergerak, beralih ke cursor biasanya layak dilakukan. Apa pun pilihannya, jangan antropomorfiskan query planner: lihat EXPLAIN, ukur latency, pahami pola akses, lalu optimalkan berdasarkan data nyata.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!