Offset vs cursor pagination adalah keputusan yang terasa sepele saat data masih kecil, tetapi efeknya sangat nyata ketika tabel tumbuh besar di produksi. Gejala yang biasanya muncul adalah endpoint daftar data makin lambat, query mulai membaca jauh lebih banyak baris daripada yang dikembalikan, proses sort menjadi mahal, dan latency melonjak pada page yang besar.
Jawaban singkatnya: OFFSET/LIMIT membuang banyak kerja karena database tetap perlu menemukan, membaca, lalu melewati sejumlah baris sebelum mengembalikan hasil. Sebaliknya, cursor pagination biasanya lebih stabil pada dataset besar karena query langsung melanjutkan dari posisi terakhir berdasarkan kolom yang terurut dan terindeks dengan baik.
Masalah nyata OFFSET/LIMIT saat tabel membesar
Pada awalnya, query seperti ini terlihat sederhana dan cukup cepat:
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;Masalah muncul ketika user membuka page 1000:
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 19980;Secara logika aplikasi, Anda hanya meminta 20 baris. Tetapi secara kerja internal, database sering kali harus:
- menemukan baris sesuai filter,
- mengurutkan atau menelusuri index sesuai urutan,
- membaca banyak baris,
- melewati 19.980 baris pertama,
- baru mengembalikan 20 baris berikutnya.
Itulah sebabnya page besar sering jauh lebih lambat daripada page awal. Bukan karena jumlah hasil yang dikembalikan bertambah, melainkan karena jumlah kerja yang dibuang makin banyak.
Gejala yang sering terlihat di produksi
- Latency page besar naik tajam, sementara page 1 masih cepat.
- Rows scanned atau jumlah baris yang dibaca jauh lebih tinggi daripada rows returned.
- Sorting mahal, terutama jika kolom pengurutan tidak cocok dengan index.
- Beban database meningkat saat banyak user mengakses listing dengan filter dan sort yang sama.
- Response time tidak stabil karena perubahan distribusi data, cache, dan konkurensi.
Masalah ini sering makin parah bila query juga memuat filter tambahan, join, atau sort pada kolom yang tidak terindeks dengan tepat.
Mengapa OFFSET besar membuang banyak kerja
OFFSET tidak berarti database bisa “melompat” begitu saja ke baris ke-20.000 dengan biaya nol. Dalam banyak kasus, engine tetap harus menelusuri hasil terurut hingga posisi itu tercapai. Jika urutan tidak didukung index yang cocok, database dapat melakukan sort atas himpunan data yang besar terlebih dahulu, lalu membuang sebagian besar hasil tersebut.
Contoh pola yang umum:
SELECT id, created_at, user_id
FROM events
WHERE user_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 50000;Query di atas mungkin tetap benar secara fungsional, tetapi mahal secara operasional. Biaya utamanya ada di:
- scan: membaca banyak entri index atau row,
- sort: mengurutkan data jika planner tidak bisa memanfaatkan index sesuai urutan,
- discard: membuang puluhan ribu hasil hanya untuk mengambil 50 baris terakhir dari potongan itu.
Inti masalah OFFSET bukan hanya jumlah baris yang dikembalikan, tetapi jumlah baris yang harus dilewati untuk sampai ke hasil tersebut.
Cursor pagination: cara kerja dan kenapa lebih stabil
Cursor pagination mengganti konsep “page number” menjadi “lanjut dari item terakhir yang sudah terlihat”. Biasanya ia memakai kolom yang terurut dan stabil, misalnya created_at ditambah id sebagai tie-breaker.
Alih-alih berkata “ambil page 1000”, aplikasi berkata “ambil 20 item setelah (created_at, id) tertentu”. Query menjadi seperti ini:
SELECT id, created_at, title
FROM posts
WHERE (created_at < :last_created_at)
OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;Dengan index yang sesuai, database tidak perlu membuang ribuan hasil sebelumnya. Ia dapat melanjutkan penelusuran dari posisi yang dekat dengan cursor terakhir. Itu sebabnya performanya cenderung lebih konsisten, bahkan saat tabel sangat besar.
Kenapa perlu kolom tie-breaker seperti id
created_at sering tidak unik. Jika dua baris memiliki timestamp yang sama, pagination bisa:
- mengulang data yang sama di halaman berikutnya, atau
- melewatkan data tertentu.
Karena itu, gunakan urutan yang deterministik, misalnya:
ORDER BY created_at DESC, id DESCLalu cursor juga harus menyimpan dua nilai tersebut.
Contoh respons API berbasis cursor
{
"data": [
{ "id": 105, "created_at": "2025-04-01T10:15:00Z", "title": "..." },
{ "id": 104, "created_at": "2025-04-01T10:10:00Z", "title": "..." }
],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNS0wNC0wMVQxMDoxMDowMFoiLCJpZCI6MTA0fQ=="
}Secara praktik, cursor biasanya di-encode agar API tidak mengekspos format internal secara mentah dan lebih mudah divalidasi.
Peran index pada kolom sort dan filter
Pagination yang cepat tidak hanya ditentukan oleh jenis pagination, tetapi juga oleh index yang cocok dengan pola query. Jika query melakukan filter pada satu kolom dan sort pada kolom lain, index harus dirancang mengikuti pola itu.
Skenario umum: filter + sort
Misalnya query Anda selalu mengambil post yang sudah dipublikasikan, lalu diurutkan berdasarkan waktu terbaru:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;Secara umum, database lebih mudah bekerja efisien jika ada index yang mendukung kolom filter dan urutan tersebut. Bentuk pastinya bergantung pada engine database, tetapi prinsipnya sama:
- kolom yang sering dipakai untuk filter perlu dipertimbangkan dalam index,
- kolom untuk sort juga harus sesuai dengan urutan query,
- gunakan tie-breaker yang stabil agar urutan deterministik.
Untuk skenario di atas, pendekatan umum adalah mempertimbangkan index komposit yang relevan dengan status, created_at, dan id.
Skenario cursor dengan created_at dan id
Jika Anda memakai cursor berbasis:
ORDER BY created_at DESC, id DESCmaka index komposit pada pasangan kolom itu biasanya sangat membantu, karena planner dapat menelusuri urutan yang sama dengan query tanpa sort tambahan yang mahal.
Index yang baik untuk cursor pagination sering kali juga memperbaiki performa page pertama pada OFFSET, tetapi tidak menghilangkan biaya membuang baris pada OFFSET besar.
Cara membaca EXPLAIN dan EXPLAIN ANALYZE secara umum
Nama field detail berbeda antar database, tetapi ada beberapa hal yang hampir selalu relevan saat Anda menganalisis query pagination.
Yang perlu diperhatikan
- Apakah database memakai index atau full scan.
- Perkiraan vs realisasi jumlah baris yang dibaca.
- Ada operasi sort atau tidak, dan seberapa mahal biayanya.
- Jumlah row yang diproses dibanding row yang benar-benar dikembalikan.
- Waktu aktual pada tiap tahap jika memakai
EXPLAIN ANALYZE.
Tanda OFFSET mulai bermasalah
Saat membaca hasil explain, waspadai pola berikut:
- query mengembalikan 20 row tetapi membaca ribuan atau jutaan row,
- planner harus melakukan sort pada himpunan data besar sebelum menerapkan limit,
- waktu terbesar ada pada scan atau sort, bukan pada pengiriman hasil,
- jumlah kerja meningkat seiring page number naik.
Contoh interpretasi praktis
Jika Anda melihat query page kecil cepat, tetapi page besar lambat, lakukan uji dengan query yang sama hanya berbeda pada OFFSET. Bila row scan dan waktu eksekusi naik sejalan dengan OFFSET, itu sinyal kuat bahwa bottleneck memang berasal dari model pagination, bukan semata dari jaringan atau aplikasi.
Untuk cursor pagination, Anda ingin melihat planner mampu memanfaatkan index dan membaca himpunan row yang dekat dengan jumlah limit, bukan ribuan kali lebih banyak.
Contoh SQL generik: offset vs cursor
Offset pagination
-- page ke-1
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
-- page ke-500
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 9980;Kelebihan pendekatan ini adalah sederhana, mudah dipahami, dan cocok untuk UI dengan nomor halaman. Kekurangannya, biaya cenderung bertambah seiring OFFSET membesar.
Cursor pagination
-- request pertama
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- request berikutnya, pakai item terakhir dari halaman sebelumnya
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
AND (
created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;Kelebihannya adalah performa lebih stabil dan cocok untuk infinite scroll atau feed. Kekurangannya, implementasi API dan UX sedikit lebih kompleks, terutama jika pengguna ingin lompat langsung ke page tertentu.
Trade-off UX dan implementasi API
Kapan offset lebih nyaman untuk UX
- ada kebutuhan nomor halaman yang jelas,
- pengguna perlu lompat ke page tertentu,
- dataset relatif kecil atau page dalam yang jarang diakses,
- backoffice sederhana dengan beban rendah.
Kapan cursor lebih cocok
- feed, timeline, audit log, event stream, activity list,
- traffic tinggi pada endpoint listing,
- tabel tumbuh terus dan ukuran data besar,
- pengalaman pengguna lebih condong ke next/previous atau infinite scroll daripada nomor halaman.
Konsekuensi di level API
Dengan offset, kontrak API biasanya sederhana:
GET /posts?page=3&per_page=20Dengan cursor, kontraknya berubah menjadi:
GET /posts?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...IiwiaWQiOjEwNH0=Beberapa hal yang perlu diperhatikan:
- cursor sebaiknya dianggap opaque, bukan format yang diutak-atik client,
- cursor harus tervalidasi dengan baik,
- urutan dan filter harus konsisten antara request pertama dan berikutnya,
- jika filter berubah, cursor lama biasanya tidak valid lagi.
Masalah konsistensi data saat ada insert dan delete
Salah satu alasan cursor pagination sering lebih disukai untuk data yang aktif berubah adalah perilakunya yang lebih alami terhadap insert baru. Pada offset pagination, jika ada baris baru masuk di atas hasil, item pada page berikutnya bisa bergeser. Akibatnya user dapat melihat data duplikat atau kehilangan beberapa item ketika berpindah halaman.
Cursor pagination juga tidak sepenuhnya kebal terhadap perubahan data, tetapi karena ia melanjutkan dari posisi terakhir yang terlihat, hasil biasanya lebih konsisten untuk alur baca berurutan.
Hal yang tetap perlu diwaspadai:
- jika kolom sort dapat berubah setelah row dibuat, cursor bisa menjadi tidak stabil,
- jika menggunakan kolom non-unik tanpa tie-breaker, urutan bisa ambigu,
- jika ada kebutuhan snapshot yang benar-benar konsisten lintas banyak page, Anda mungkin perlu strategi tambahan di level transaksi atau aplikasi.
Anti-pattern yang sering membuat pagination lambat
- ORDER BY kolom tanpa index yang sesuai.
- Sort tidak deterministik, misalnya hanya
ORDER BY created_at DESCpadahal banyak nilai sama. - OFFSET sangat besar pada endpoint panas yang dipanggil terus-menerus.
- Memakai SELECT * padahal hanya butuh beberapa kolom untuk list.
- Join berat sebelum pagination tanpa memastikan planner bisa membatasi row lebih awal.
- Cursor berbasis kolom yang bisa berubah, seperti skor yang sering di-recompute tanpa strategi khusus.
- Menggabungkan banyak filter opsional tetapi hanya punya index yang cocok untuk sebagian kecil pola query.
Anti-pattern lain yang sering tidak disadari adalah memaksa halaman sangat dalam untuk kebutuhan ekspor. Untuk kasus seperti itu, pagination endpoint bukan selalu alat yang tepat. Batch processing atau job asinkron sering lebih masuk akal.
Metrik yang perlu dipantau
Jangan menilai pagination hanya dari rata-rata response time. Untuk endpoint list di produksi, pantau setidaknya:
- p95/p99 latency per endpoint, bukan hanya average,
- rows scanned vs rows returned,
- query execution time di database,
- frequency sort spill atau indikasi sort berat jika database Anda menyediakannya,
- CPU dan I/O database saat traffic puncak,
- slow query log untuk page besar atau filter tertentu,
- error rate dan timeout di layer API.
Jika Anda memigrasikan endpoint populer dari offset ke cursor, bandingkan distribusi latency sebelum dan sesudah, bukan hanya satu-dua percobaan manual.
Checklist migrasi aman dari offset ke cursor
- Identifikasi endpoint yang paling terdampak
Cari endpoint list dengan page dalam, traffic tinggi, atau query yang masuk slow query log. - Tentukan urutan yang stabil
Pilih kolom sort utama dan tie-breaker, misalnyacreated_at+id. - Pastikan ada index yang mendukung
Sesuaikan dengan pola filter dan urutan yang benar-benar dipakai. - Desain format cursor
Simpan nilai yang diperlukan untuk melanjutkan pagination, lalu encode secara aman. - Pertahankan kompatibilitas sementara
Dukung offset dan cursor untuk masa transisi jika perlu, terutama untuk client lama. - Validasi konsistensi hasil
Uji apakah ada duplikasi atau item hilang saat data berubah di tengah proses pagination. - Tambahkan observability
Log query lambat, page depth, limit, dan penggunaan cursor invalid. - Rollout bertahap
Aktifkan pada sebagian trafik atau endpoint tertentu lebih dulu. - Perbarui dokumentasi API
Jelaskan bahwa client harus memakainext_cursordari respons, bukan membangun sendiri. - Siapkan fallback
Jika ada bug pada cursor parsing atau urutan, Anda perlu jalur rollback yang jelas.
Kapan offset masih cukup layak
Offset bukan berarti selalu salah. Ia masih layak jika:
- tabel masih kecil atau pertumbuhannya lambat,
- page dalam hampir tidak pernah diakses,
- query sudah didukung index yang baik dan tetap ringan,
- UI benar-benar membutuhkan nomor halaman dan lompat langsung,
- fitur bersifat internal dengan beban rendah,
- kompleksitas implementasi cursor belum sebanding dengan manfaatnya.
Prinsip yang sehat adalah menggunakan offset sampai terbukti menjadi bottleneck, lalu pindah ke cursor pada endpoint yang memang membutuhkan. Namun jika Anda sedang merancang feed, log, atau timeline yang jelas akan membesar, biasanya lebih efisien memilih cursor sejak awal.
Kesimpulan
Pada tabel yang membesar di produksi, perbedaan utama dalam offset vs cursor pagination ada pada jumlah kerja yang harus dilakukan database. OFFSET/LIMIT mudah diimplementasikan, tetapi biaya query biasanya naik seiring page number membesar karena database harus membaca dan membuang banyak baris. Cursor pagination lebih cocok untuk dataset besar karena ia melanjutkan dari posisi terakhir dengan bantuan urutan yang stabil dan index yang tepat.
Jika endpoint list Anda mulai lambat, jangan hanya melihat hasil query yang dikembalikan. Lihat juga berapa banyak baris yang discan, apakah ada sort mahal, dan apakah planner benar-benar memanfaatkan index. Dari sana, Anda bisa menilai apakah cukup menambah index, membatasi penggunaan page dalam, atau memang sudah waktunya memigrasikan API dari offset ke cursor.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!