Keyset pagination vs OFFSET menjadi penting ketika endpoint feed, timeline, atau listing mulai terasa lambat di halaman-halaman yang lebih dalam. Masalah utamanya bukan pada LIMIT, tetapi pada OFFSET: semakin besar nilainya, semakin banyak baris yang tetap harus dibaca, diurutkan, atau dilewati oleh database sebelum hasil bisa dikirim.

Untuk query feed yang terus bertambah, keyset pagination biasanya lebih stabil dibanding LIMIT ... OFFSET ... karena database tidak perlu menghitung lalu membuang ribuan atau jutaan baris sebelumnya. Sebaliknya, query cukup melanjutkan dari posisi terakhir berdasarkan kolom sort yang terindeks, misalnya created_at dan id.

Mengapa LIMIT OFFSET makin lambat saat data tumbuh?

Banyak implementasi feed dimulai dengan pola berikut karena sederhana dan mudah dipahami:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;

Halaman berikutnya:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;

Secara logika hasilnya benar. Namun secara eksekusi, database tidak bisa langsung “lompat” ke baris ke-10001 tanpa biaya. Ia tetap perlu mencari baris yang cocok dengan WHERE, menjaga urutan sesuai ORDER BY, lalu skip sebanyak OFFSET yang diminta.

Bottleneck nyata yang sering muncul

  • Index scan besar: walaupun memakai index, database tetap harus berjalan melewati banyak entri index sampai mencapai offset yang diminta.
  • Sorting mahal: jika index tidak mendukung urutan yang diminta, database perlu sort tambahan. Ini makin berat saat jumlah kandidat baris besar.
  • Biaya skip row: OFFSET 100000 berarti banyak baris dibaca lalu dibuang sebelum 20 baris hasil diambil.
  • I/O meningkat: semakin dalam halaman, semakin banyak page index atau data yang disentuh.
  • Hasil tidak stabil saat data berubah: insert atau delete di antara dua request bisa membuat item duplikat atau terlewat.

Masalah ini terasa jelas pada feed aktif, misalnya tabel posts atau events yang terus menerima data baru.

Kenapa OFFSET tidak skala baik untuk feed

Bayangkan urutan feed berdasarkan created_at DESC. Untuk mengambil halaman ke-500, database perlu mengetahui 499 halaman sebelumnya agar bisa membuangnya. Walaupun ada index pada created_at, prosesnya tetap bersifat walk through sejumlah besar entri.

Dalam praktiknya, pola ini sering terlihat seperti:

  1. Ambil semua kandidat yang cocok dengan WHERE.
  2. Pastikan urutannya sesuai ORDER BY.
  3. Lewati OFFSET baris.
  4. Kembalikan LIMIT baris berikutnya.

Ketika offset kecil, biaya ini sering masih dapat diterima. Ketika offset besar, latensi meningkat dan konsumsi resource ikut naik. Ini berlaku baik di PostgreSQL maupun MySQL, meskipun detail planner dan operatornya berbeda.

Contoh pola yang membuat query makin mahal

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 50000;

Di sini ada dua kemungkinan buruk:

  • Jika tidak ada index yang sesuai dengan WHERE + ORDER BY, database mungkin harus memfilter banyak baris lalu melakukan sort besar.
  • Jika index ada, offset besar tetap membuat engine berjalan jauh di index untuk melewati 50000 baris lebih dulu.

Keyset pagination: prinsip dan kenapa lebih cepat

Keyset pagination mengganti konsep “halaman ke-N” menjadi “lanjut dari item terakhir yang sudah saya terima”. Jadi query tidak lagi berkata skip 50000 baris, tetapi ambil 20 baris setelah cursor ini.

Misalnya halaman pertama feed:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;

Misalkan baris terakhir hasil punya:

  • created_at = '2025-04-10 09:30:00'
  • id = 12345

Maka halaman berikutnya menggunakan cursor tersebut:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
  AND (
    created_at < '2025-04-10 09:30:00'
    OR (created_at = '2025-04-10 09:30:00' AND id < 12345)
  )
ORDER BY created_at DESC, id DESC
LIMIT 20;

Query ini lebih efisien karena database bisa memanfaatkan index untuk langsung menuju rentang yang relevan, bukan membaca dan membuang semua baris sebelumnya.

Mengapa perlu tie-breaker id

created_at sering tidak unik. Banyak baris bisa punya timestamp yang sama, terutama jika resolusi waktu terbatas atau insert terjadi sangat cepat. Jika Anda hanya memakai:

WHERE created_at < :last_created_at

maka baris dengan timestamp yang sama berisiko terlewat atau muncul ganda. Karena itu, gunakan urutan stabil seperti:

ORDER BY created_at DESC, id DESC

dan jadikan pasangan kolom tersebut sebagai cursor.

Desain index yang tepat untuk keyset pagination

Keyset pagination hanya efektif jika index mendukung pola filter dan urutannya. Aturan praktisnya: index harus selaras dengan kolom pada WHERE yang selektif dan urutan pada ORDER BY.

Contoh index untuk feed posts

Jika query utamanya seperti ini:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
  AND (
    created_at < :cursor_created_at
    OR (created_at = :cursor_created_at AND id < :cursor_id)
  )
ORDER BY created_at DESC, id DESC
LIMIT 20;

Maka index yang lazim dipertimbangkan adalah kombinasi kolom filter dan sort, misalnya:

-- PostgreSQL / MySQL (contoh umum)
CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at DESC, id DESC);

Poin pentingnya bukan sintaks persis di semua engine, melainkan susunan logis kolomnya:

  • status membantu menyaring subset data yang relevan.
  • created_at, id mengikuti urutan sort dan cursor.

Jika query sering hanya mengambil kolom tertentu, planner kadang dapat memanfaatkan index dengan lebih efisien. Namun jangan mengandalkan ini tanpa verifikasi EXPLAIN.

Jebakan umum: index ada, tapi tidak membantu

  • Index hanya di created_at, padahal query juga memfilter status.
  • ORDER BY tidak cocok dengan urutan index.
  • Sort memakai kolom tambahan yang tidak ada di index.
  • WHERE dan ORDER BY tidak sejalan, sehingga database tetap perlu sort terpisah.
  • Menggunakan fungsi pada kolom sort, misalnya ORDER BY DATE(created_at), yang sering merusak pemanfaatan index biasa.

Perbandingan query OFFSET vs keyset

OFFSET pagination

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20000;

Kelebihan:

  • Mudah diimplementasikan.
  • Mudah mendukung konsep halaman numerik: page 1, 2, 3, dst.
  • Cocok untuk data kecil atau admin listing sederhana.

Kekurangan:

  • Makin lambat saat halaman makin dalam.
  • Rentan hasil tidak konsisten saat ada insert/delete di antara request.
  • Resource database lebih tinggi untuk offset besar.

Keyset pagination

SELECT id, title, created_at
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;

Kelebihan:

  • Performa lebih stabil pada dataset besar.
  • Lebih cocok untuk feed, infinite scroll, timeline, activity log.
  • Lebih konsisten ketika data baru terus masuk.

Kekurangan:

  • Tidak alami untuk lompat ke halaman 500 secara langsung.
  • Perlu desain cursor dan aturan urutan yang konsisten.
  • Implementasi API dan frontend sedikit lebih kompleks.

Syarat penting: kolom sort harus stabil dan deterministik

Agar keyset pagination aman, urutan hasil harus deterministik. Artinya, dua query berurutan dengan cursor tertentu harus punya aturan urut yang jelas dan tidak ambigu.

Pilih kolom yang tepat

  • Bagus: created_at DESC, id DESC
  • Berisiko: score DESC tanpa tie-breaker jika banyak nilai sama
  • Kurang cocok: kolom yang sering berubah, misalnya updated_at untuk feed utama, kecuali memang itu yang diinginkan

Jika urutan berdasarkan kolom yang mutable, item bisa “berpindah posisi” di antara request dan menghasilkan perilaku yang membingungkan. Kadang ini memang sesuai kebutuhan produk, tetapi harus dipahami sebagai trade-off, bukan bug database.

Gunakan tie-breaker unik

Jika kolom utama tidak unik, tambahkan kolom unik yang konsisten, biasanya id. Contoh:

ORDER BY score DESC, id DESC

Lalu cursor juga harus menyimpan score dan id.

Desain API cursor yang praktis

Di level API, keyset pagination biasanya tidak mengirim page=123, melainkan cursor. Bentuk sederhananya bisa berupa pasangan nilai mentah, tetapi umumnya lebih aman dan fleksibel jika dienkode.

Contoh respons API

{
  "data": [
    {"id": 12500, "title": "Post A", "created_at": "2025-04-10T10:00:00Z"},
    {"id": 12499, "title": "Post B", "created_at": "2025-04-10T09:59:58Z"}
  ],
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNS0wNC0xMFQwOTo1OTo1OFoiLCJpZCI6MTI0OTl9"
}

Isi cursor biasanya memuat nilai kolom sort terakhir, misalnya:

{"created_at":"2025-04-10T09:59:58Z","id":12499}

Lalu dienkode agar tidak terlalu bergantung pada format internal. Encoding bukan fitur keamanan. Jika cursor mengandung data sensitif atau Anda ingin mencegah manipulasi, gunakan penandatanganan atau validasi tambahan.

Arah pagination

Untuk feed, yang paling umum adalah forward pagination: ambil item setelah cursor terakhir. Jika perlu mendukung tombol previous, desainnya bisa lebih rumit karena Anda perlu membalik arah pencarian atau menyimpan cursor terpisah.

Untuk infinite scroll atau feed terbaru-ke-lama, dukungan next cursor saja sering sudah cukup dan jauh lebih sederhana dibanding memaksakan nomor halaman.

Cara membaca EXPLAIN secara dasar

Anda tidak perlu menjadi ahli planner untuk mendeteksi masalah utama. Saat membandingkan OFFSET dan keyset, fokuslah pada hal-hal berikut:

Yang perlu diperhatikan di EXPLAIN

  • Apakah query memakai index scan atau harus scan besar?
  • Apakah ada langkah sort terpisah? Jika ya, cek apakah index belum mendukung ORDER BY.
  • Berapa banyak row yang diperkirakan/diolah sebelum LIMIT terpenuhi?
  • Apakah filter terjadi lebih awal atau justru setelah banyak data dibaca?

Sinyal masalah pada query OFFSET

  • Jumlah row yang diproses jauh lebih besar daripada LIMIT.
  • Muncul operasi sort pada kandidat row yang besar.
  • Planner tetap harus membaca banyak entri meskipun hasil akhirnya hanya 20 row.

Sinyal query keyset sehat

  • Planner dapat menggunakan kondisi range pada kolom index.
  • Jumlah row yang diproses mendekati jumlah hasil yang diminta.
  • Tidak perlu sort besar di luar index order.

Untuk PostgreSQL, EXPLAIN (ANALYZE, BUFFERS) sering sangat membantu saat investigasi nyata. Untuk MySQL, EXPLAIN juga berguna untuk melihat apakah index digunakan dan apakah ada indikasi sort atau scan yang tidak efisien.

Contoh implementasi yang benar dan yang salah

Salah: sort tidak konsisten

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
  AND created_at < :last_created_at
ORDER BY created_at DESC
LIMIT 20;

Masalahnya: jika banyak row memiliki created_at sama, hasil bisa lompat atau duplikat.

Benar: ada tie-breaker

SELECT id, title, created_at
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;

Salah: index tidak menutup WHERE + ORDER BY

Misalnya query memfilter status tapi index hanya (created_at, id). Pada sebagian workload, database tetap harus bekerja lebih keras karena subset status='published' tidak bisa dipersempit secara efisien sejak awal.

Lebih tepat: index selaras dengan query utama

CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at DESC, id DESC);

Kapan OFFSET masih layak dipakai?

Walaupun keyset pagination unggul untuk feed besar, OFFSET tidak selalu salah. Ada beberapa kasus di mana ia masih layak:

  • Dataset kecil dan pertumbuhan belum signifikan.
  • Backoffice/admin yang butuh lompat ke halaman tertentu.
  • Listing statis yang jarang berubah.
  • Analitik atau pelaporan yang lebih mementingkan navigasi numerik daripada latensi halaman dalam.
  • Ekspektasi UX memang berbasis page number.

Intinya, jangan memigrasikan semua listing ke keyset tanpa alasan. Gunakan sesuai pola akses. Untuk feed aktif, keyset hampir selalu lebih cocok. Untuk tabel admin dengan 20 halaman, OFFSET mungkin sudah cukup.

Trade-off UX dan produk

Keuntungan UX keyset

  • Infinite scroll lebih natural.
  • Lebih stabil terhadap insert baru di bagian atas feed.
  • Waktu muat halaman lanjutan cenderung konsisten.

Keterbatasan UX keyset

  • Sulit mendukung “pergi ke halaman 37”.
  • Perlu perubahan mental model dari page number ke cursor.
  • Bookmark halaman tertentu tidak sesederhana ?page=37.

Karena itu, tidak jarang sistem memakai dua strategi berbeda: keyset untuk API feed publik, OFFSET untuk dashboard internal atau pencarian yang butuh page number.

Checklist migrasi endpoint existing dari OFFSET ke keyset

  1. Identifikasi endpoint yang paling bermasalah: biasanya feed dengan offset besar, bukan listing kecil.
  2. Tentukan urutan stabil: misalnya created_at DESC, id DESC.
  3. Pastikan ada tie-breaker unik.
  4. Tambahkan index yang sesuai dengan WHERE + ORDER BY.
  5. Ubah kontrak API dari page/offset ke cursor.
  6. Encode cursor agar formatnya tidak berantakan di sisi klien.
  7. Tambahkan validasi cursor untuk input rusak atau manipulatif.
  8. Uji duplikasi dan missing row saat ada data baru masuk di tengah pagination.
  9. Bandingkan EXPLAIN sebelum dan sesudah.
  10. Rollout bertahap jika endpoint dipakai banyak klien.

Debugging tips saat hasil keyset terasa aneh

1. Ada item ganda antar halaman

Biasanya karena:

  • Tidak ada tie-breaker unik.
  • Cursor tidak menyimpan semua kolom sort.
  • Urutan di query berbeda dengan logika pembentukan cursor.

2. Ada item yang hilang

Sering terjadi karena operator perbandingan salah. Contoh, untuk urutan DESC, halaman berikutnya umumnya memakai <, bukan >. Jika kombinasi kondisi equality dan tie-breaker salah, beberapa row bisa terlewati.

3. Query tetap lambat meski sudah keyset

Cek hal berikut:

  • Apakah index benar-benar sesuai?
  • Apakah ada filter tambahan yang belum masuk strategi index?
  • Apakah query memilih terlalu banyak kolom besar?
  • Apakah planner masih melakukan sort karena urutan tidak cocok?
  • Apakah kondisi WHERE terlalu kompleks atau melibatkan fungsi?

4. Hasil berubah-ubah saat data aktif

Ini bisa jadi perilaku alami jika kolom sort berubah atau ada insert/delete terus-menerus. Tentukan apakah aplikasi membutuhkan konsistensi snapshot, atau cukup konsisten secara operasional untuk use case feed.

Contoh untuk tabel events

Konsep yang sama berlaku pada tabel events, misalnya activity stream:

SELECT id, actor_id, event_type, created_at
FROM events
WHERE tenant_id = :tenant_id
  AND (
    created_at < :last_created_at
    OR (created_at = :last_created_at AND id < :last_id)
  )
ORDER BY created_at DESC, id DESC
LIMIT 50;

Index yang lazim dipertimbangkan:

CREATE INDEX idx_events_tenant_created_id
ON events (tenant_id, created_at DESC, id DESC);

Di sini tenant_id penting jika feed dipisahkan per tenant atau workspace. Tanpa kolom ini di index, database bisa saja membaca rentang yang lebih luas dari yang dibutuhkan.

Kesimpulan

LIMIT OFFSET melambat bukan karena syntax-nya buruk, tetapi karena model kerjanya memang mahal untuk feed yang terus tumbuh: database harus membaca, mengurutkan, dan melewati banyak baris sebelum mengembalikan hasil. Biaya ini naik seiring kedalaman halaman.

Keyset pagination lebih cocok untuk query feed/listing aktif karena mengubah masalah dari “skip banyak row” menjadi “lanjut dari posisi terakhir”, sehingga index bisa dipakai sebagai range scan yang lebih efisien. Supaya berhasil, Anda perlu urutan yang stabil, tie-breaker unik seperti id, index yang benar-benar menutup WHERE + ORDER BY, dan desain cursor API yang konsisten.

Jika endpoint Anda adalah timeline, activity feed, atau infinite scroll yang mulai lambat di offset besar, migrasi ke keyset pagination biasanya memberi perbaikan paling nyata. Jika endpoint Anda adalah admin table kecil dengan nomor halaman, OFFSET mungkin masih cukup dan lebih sederhana.