Keyset pagination adalah teknik pagination yang mengambil halaman berikutnya berdasarkan nilai baris terakhir yang sudah tampil, bukan berdasarkan nomor halaman dan OFFSET. Untuk feed, timeline, activity log, atau daftar data yang terus bertambah, pendekatan ini biasanya jauh lebih stabil dibanding OFFSET/LIMIT karena database tidak perlu terus-menerus membuang ribuan atau jutaan baris di depan.
Masalah yang sering terlihat di produksi biasanya jelas: page 1 cepat, page 10 masih aman, tetapi page 500 mulai lambat. Selain itu, hasil pagination berbasis OFFSET bisa tidak konsisten saat ada data baru masuk di antara request pengguna. Jika Anda sedang menangani feed SQL yang membesar, keyset pagination hampir selalu lebih cocok daripada numbered pagination tradisional.
Mengapa OFFSET/LIMIT Memburuk saat Tabel Membesar
Query seperti ini terlihat sederhana:
SELECT id, created_at, author_id, body
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Masalahnya bukan pada LIMIT 20, tetapi pada OFFSET 10000. Untuk menemukan 20 baris yang diminta, database tetap harus berjalan melewati baris-baris sebelumnya sesuai urutan sort. Walaupun ada index, engine tetap perlu men-scan atau menelusuri banyak entri index sebelum sampai ke titik yang diminta.
Gejala nyata yang biasanya muncul:
- Latency naik pada page tinggi karena makin banyak baris yang harus dilewati.
- IO dan CPU bertambah saat trafik tinggi dan banyak user membuka halaman dalam posisi berbeda.
- Hasil tidak stabil jika ada insert baru, sehingga item bisa loncat, duplikat, atau terlewat.
Contoh inkonsistensi:
- User membuka page 1 dengan
OFFSET 0 LIMIT 20. - Sebelum user membuka page 2, ada 3 post baru masuk di posisi paling atas.
- User membuka page 2 dengan
OFFSET 20 LIMIT 20.
Karena posisi data bergeser, page 2 bisa berisi sebagian item yang sudah tampil di page 1, atau malah melewatkan item yang seharusnya terlihat.
Cara Kerja Keyset Pagination
Alih-alih berkata "ambil 20 baris setelah melewati 10000 baris", keyset pagination berkata "ambil 20 baris setelah item terakhir yang tadi saya lihat". Jadi acuan pagination adalah cursor, bukan nomor halaman.
Misalnya feed diurutkan dengan:
ORDER BY created_at DESC, id DESCJika item terakhir pada halaman sekarang memiliki:
created_at = '2025-04-10 12:30:00'
id = 98765Maka halaman berikutnya diambil dengan query:
SELECT id, created_at, author_id, body
FROM posts
WHERE (created_at, id) < ('2025-04-10 12:30:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 20;Maknanya: ambil baris yang berada setelah cursor tersebut dalam urutan yang sama. Database dapat memanfaatkan index untuk langsung melanjutkan dari posisi itu, bukan mengulang dari awal lalu membuang ribuan baris.
Mengapa Perlu Lebih dari Satu Kolom
Jika Anda hanya memakai created_at, akan ada masalah ketika banyak baris memiliki timestamp yang sama. Hasil bisa tidak deterministik: ada item yang lompat atau terduplikasi antar halaman.
Karena itu, gunakan urutan yang stabil dan unik. Pola paling umum:
ORDER BY created_at DESC, id DESCORDER BY score DESC, id DESCORDER BY published_at DESC, id DESC
id di sini berfungsi sebagai tie-breaker agar urutan total benar-benar konsisten.
Syarat Kolom Pengurutan yang Stabil
Keyset pagination bekerja baik jika kolom pengurutan memenuhi beberapa syarat:
- Urutannya deterministik: kombinasi kolom sort harus menghasilkan urutan total yang jelas.
- Relatif stabil: nilai sort tidak sering berubah setelah data tampil ke user.
- Bisa di-index: agar pencarian berdasarkan cursor tetap efisien.
Pilihan yang Umum Dipakai
created_at + id: cocok untuk feed terbaru, audit log, notifikasi, event stream.idsaja: cukup jika ID selalu meningkat dan feed memang mengikuti urutan ID.published_at + id: cocok untuk artikel atau konten yang dipublikasikan terjadwal.
Yang Perlu Diwaspadai
- Kolom sort sering di-update: misalnya ranking atau score yang berubah terus. Ini bisa menggeser posisi item antar request.
- Urutan tidak unik: misalnya hanya
ORDER BY statusatau hanyaORDER BY created_attanpa tie-breaker. - Sort berdasarkan ekspresi kompleks: bisa sulit di-index atau tidak efisien, tergantung database.
Untuk feed operasional, aturan aman adalah: gunakan kolom waktu atau ID yang monoton, lalu tambahkan
idsebagai tie-breaker unik.
Desain Index yang Tepat
Keyset pagination tidak otomatis cepat jika index-nya salah. Index harus mengikuti pola filter dan urutan sort.
Untuk query:
SELECT id, created_at, author_id, body
FROM posts
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;Index yang biasanya dibutuhkan adalah:
CREATE INDEX idx_posts_created_id_desc
ON posts (created_at DESC, id DESC);Jika database Anda tidak terlalu membedakan arah ASC/DESC pada struktur index, prinsip utamanya tetap sama: index harus dimulai dari kolom yang dipakai untuk sort dan cursor comparison.
Jika Ada Filter Tambahan
Feed nyata sering memiliki filter, misalnya hanya data publik atau hanya post milik tenant tertentu:
SELECT id, created_at, author_id, body
FROM posts
WHERE tenant_id = ?
AND visibility = 'public'
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;Dalam kasus ini, index perlu mempertimbangkan kolom filter yang paling selektif dan konsisten dipakai. Contoh umum:
CREATE INDEX idx_posts_tenant_visibility_created_id
ON posts (tenant_id, visibility, created_at DESC, id DESC);Urutan index yang ideal bergantung pada pola query nyata. Jangan menyalin satu pola index untuk semua kasus tanpa mengecek plan query.
Kesalahan yang Sering Terjadi
- Sudah memakai keyset, tetapi index masih hanya di
idpadahal sort utamanyacreated_at. - Kolom filter tidak masuk index, sehingga database tetap membaca terlalu banyak baris.
- Memilih terlalu banyak kolom dalam query sehingga engine harus sering kembali ke heap/table untuk lookup tambahan.
Contoh SQL: Next Page dan Previous Page
Halaman Pertama
Ambil halaman awal tanpa cursor:
SELECT id, created_at, author_id, body
FROM posts
WHERE tenant_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20;Dari hasil ini, simpan cursor item terakhir. Misalnya baris terakhir memiliki:
created_at = '2025-04-10 12:30:00'
id = 98765Next Page
SELECT id, created_at, author_id, body
FROM posts
WHERE tenant_id = 42
AND (created_at, id) < ('2025-04-10 12:30:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 20;Jika database atau gaya SQL Anda tidak memakai perbandingan tuple, gunakan bentuk ekuivalen:
SELECT id, created_at, author_id, body
FROM posts
WHERE tenant_id = 42
AND (
created_at < '2025-04-10 12:30:00'
OR (created_at = '2025-04-10 12:30:00' AND id < 98765)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;Previous Page
Untuk bergerak ke halaman sebelumnya, logikanya dibalik. Misalkan item pertama pada halaman sekarang adalah:
created_at = '2025-04-10 12:45:00'
id = 98810Query-nya:
SELECT id, created_at, author_id, body
FROM posts
WHERE tenant_id = 42
AND (created_at, id) > ('2025-04-10 12:45:00', 98810)
ORDER BY created_at ASC, id ASC
LIMIT 20;Karena query di atas mengambil data dalam arah terbalik, hasilnya biasanya perlu dibalik lagi di aplikasi agar tetap ditampilkan sebagai created_at DESC, id DESC.
Pola implementasi yang umum:
- Untuk next, pakai comparator sesuai arah sort normal.
- Untuk previous, pakai comparator kebalikannya dan balik urutan hasil di layer aplikasi.
Format Cursor di API
Di level API, cursor biasanya tidak dikirim sebagai dua parameter mentah yang mudah dimanipulasi seenaknya. Pendekatan praktis adalah mengenkode nilai sort menjadi token, misalnya JSON yang di-base64-encode.
{
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNS0wNC0xMFQxMjozMDowMFoiLCJpZCI6OTg3NjV9",
"has_more": true
}Isi cursor sebaiknya minimal berisi:
- Nilai kolom sort utama
- Tie-breaker unik, biasanya
id - Opsional: informasi arah paging atau versi format cursor
Jika filter ikut memengaruhi hasil, pastikan cursor hanya dipakai bersama filter yang sama. Praktiknya, server bisa memvalidasi parameter request agar cursor lama tidak dipakai untuk query berbeda.
Jangan Menganggap Cursor sebagai Mekanisme Keamanan
Encoding bukan enkripsi. Jika cursor mengandung data sensitif, tandatangani atau lindungi nilainya. Untuk banyak kasus, cukup simpan nilai sort yang memang tidak sensitif.
Trade-off dibanding Numbered Pagination
Kelebihan Keyset Pagination
- Lebih stabil performanya saat data membesar.
- Lebih konsisten untuk feed yang berubah karena bergerak relatif terhadap item terakhir yang terlihat.
- Cocok untuk infinite scroll, timeline, notifikasi, log, dan daftar event.
Kekurangan Keyset Pagination
- Tidak ideal untuk lompat ke page 123 karena konsep utamanya bukan nomor halaman.
- Lebih rumit di API dan UI karena harus membawa cursor next/previous.
- COUNT(*) dan total pages tidak selalu relevan untuk pengalaman feed real-time.
Bagaimana dengan COUNT(*)
Pada numbered pagination, UI sering menampilkan total data dan total halaman. Pada feed besar, COUNT(*) bisa mahal tergantung database, filter, dan beban sistem. Bahkan saat count tersedia, nilainya cepat basi jika data terus berubah.
Untuk feed, UX yang lebih realistis biasanya cukup:
- Tampilkan tombol atau token Load more
- Tampilkan has_more berdasarkan apakah hasil yang diambil melebihi batas atau tidak
- Gunakan count terpisah jika memang dibutuhkan untuk laporan, bukan untuk navigasi feed utama
Kapan Numbered Pagination Tetap Lebih Cocok
Jika pengguna perlu:
- melompat ke halaman tertentu,
- melihat total halaman,
- mencetak atau merekonsiliasi hasil berdasarkan urutan yang relatif statis,
maka numbered pagination bisa tetap lebih cocok. Contoh: daftar arsip administratif, hasil pencarian back-office yang jarang berubah, atau tabel internal yang lebih penting untuk dinavigasi secara eksplisit per halaman.
Migrasi Bertahap dari OFFSET/LIMIT ke Keyset Pagination
Migrasi paling aman biasanya dilakukan bertahap, bukan mengganti semua endpoint sekaligus.
1. Identifikasi Endpoint yang Paling Diuntungkan
Prioritaskan endpoint dengan ciri:
- trafik tinggi,
- page dalam sering diakses,
- data terus bertambah,
- keluhan performa atau duplikasi hasil sudah terlihat.
2. Tetapkan Urutan Sort yang Stabil
Pilih urutan final, misalnya:
ORDER BY created_at DESC, id DESCPastikan semua query di endpoint tersebut konsisten memakai urutan yang sama.
3. Tambahkan Index yang Sesuai
Buat index berdasarkan kombinasi filter dan sort yang benar. Lakukan verifikasi dengan EXPLAIN sebelum dan sesudah perubahan.
4. Tambahkan Dukungan Cursor di API
Jangan langsung menghapus page dan offset. Tambahkan parameter baru, misalnya:
cursorlimit
Lalu jalankan dua mode sementara:
- mode lama untuk kompatibilitas,
- mode baru untuk klien yang siap migrasi.
5. Ubah Respons API
Contoh struktur respons:
{
"items": [
{ "id": 98820, "created_at": "2025-04-10T12:50:00Z", "body": "..." }
],
"next_cursor": "...",
"prev_cursor": "...",
"has_more": true
}Jika previous page belum diperlukan, Anda bisa mulai hanya dengan next_cursor.
6. Perbarui Klien Secara Bertahap
Untuk web atau mobile:
- ubah pagination dari page number ke load more atau infinite scroll,
- simpan cursor dari respons terakhir,
- reset cursor saat filter berubah.
7. Monitor Konsistensi dan Performa
Setelah rollout, periksa:
- latency per endpoint,
- jumlah baris yang dibaca,
- duplikasi item antar halaman,
- keluhan UI saat refresh atau back navigation.
Checklist Evaluasi EXPLAIN
Saat mengecek query keyset pagination, gunakan EXPLAIN atau alat plan query yang setara. Fokus pada hal-hal berikut:
- Apakah index yang benar benar-benar dipakai?
- Apakah urutan sort sesuai dengan index?
- Apakah jumlah baris yang diperkirakan/terbaca masuk akal?
- Apakah masih ada sort tambahan yang mahal?
- Apakah filter tenant/status/visibility ikut terbantu oleh index?
- Apakah query mengambil terlalu banyak kolom sehingga lookup tambahan menjadi mahal?
Tanda yang perlu dicurigai:
- planner memilih full scan padahal tabel besar,
- ada operasi sort besar setelah filtering,
- jumlah row examined tetap tinggi meski limit kecil,
- query dengan cursor masih membaca data jauh lebih banyak daripada jumlah hasil.
Target utamanya bukan sekadar “memakai index”, tetapi membuat query dapat seek ke posisi cursor dan membaca sedikit baris tambahan sebelum memenuhi LIMIT.
Kesalahan Implementasi yang Sering Menyebabkan Bug
1. Tidak Memakai Tie-Breaker Unik
Jika banyak baris punya created_at sama dan Anda tidak menambahkan id, hasil antar halaman bisa tidak konsisten.
2. Comparator Salah Arah
Jika urutannya DESC, maka next page biasanya memakai <, bukan >. Banyak bug pagination terjadi hanya karena operator perbandingan terbalik.
3. Filter Berubah tetapi Cursor Lama Tetap Dipakai
Cursor dari feed tenant_id=42 tidak boleh dipakai untuk feed tenant_id=99 atau untuk filter status yang berbeda.
4. Menganggap Keyset Selalu Konsisten untuk Data yang Sering Diupdate
Jika kolom sort berubah setelah item tampil, posisi item bisa berpindah. Ini bukan bug SQL, tetapi konsekuensi dari urutan data yang memang berubah.
5. Tetap Menghitung Total Halaman seperti Sistem Lama
Jika desain UI masih memaksa konsep page number dan total pages, manfaat keyset pagination bisa berkurang atau malah membuat implementasi menjadi aneh.
Kapan Keyset Pagination Tidak Cocok
Meskipun sangat berguna, keyset pagination bukan solusi universal.
Pendekatan ini kurang cocok jika:
- pengguna harus lompat ke halaman arbitrer, misalnya page 237;
- urutan data tidak stabil dan sering dirombak oleh update massal;
- sort sangat dinamis, misalnya user bebas memilih banyak kombinasi sort yang tidak semuanya punya index memadai;
- kebutuhan utama adalah pelaporan statis, bukan feed yang terus bergerak;
- kompatibilitas lama sangat ketat dan semua klien sudah bergantung pada nomor halaman eksplisit.
Dalam situasi seperti itu, Anda bisa tetap memakai OFFSET/LIMIT, membatasi page maksimum, menambahkan cache, atau menyediakan dua mode pagination yang berbeda sesuai use case.
Rekomendasi Praktis
Jika Anda mengelola feed SQL yang terus membesar, gunakan aturan sederhana ini:
- Untuk feed, timeline, notifikasi, log, event stream → prioritaskan keyset pagination.
- Gunakan urutan stabil seperti
created_at DESC, id DESC. - Buat index yang selaras dengan filter dan sort.
- Representasikan posisi dengan cursor, bukan nomor halaman.
- Sesuaikan UX menjadi load more atau infinite scroll jika memungkinkan.
Hasilnya biasanya bukan hanya query yang lebih cepat pada dataset besar, tetapi juga perilaku feed yang lebih masuk akal ketika data baru terus masuk. Itulah alasan utama mengapa keyset pagination menjadi pendekatan yang lebih tepat daripada OFFSET/LIMIT untuk banyak aplikasi modern yang berorientasi feed.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!