GraphQL Cursor Pagination biasanya mulai dibutuhkan ketika query berbasis OFFSET/LIMIT tidak lagi stabil dan cepat. Gejala yang umum muncul adalah latency meningkat pada halaman dalam, beban database naik walaupun ukuran respons tetap kecil, dan hasil pagination berubah-ubah saat ada baris baru masuk atau data lama diperbarui.

Masalah utamanya bukan di GraphQL itu sendiri, melainkan pada cara database mengeksekusi query. OFFSET memaksa database melewati banyak baris sebelum mengembalikan hasil, sedangkan cursor pagination memanfaatkan kondisi WHERE yang bisa langsung melompat ke posisi berikutnya dengan bantuan index yang sesuai. Jika API Anda melayani feed, daftar transaksi, log aktivitas, atau data yang terus bertambah, inilah pola yang biasanya lebih aman dan lebih murah.

Gejala nyata saat OFFSET mulai menjadi bottleneck

Pada tahap awal, query seperti LIMIT 20 OFFSET 0 atau OFFSET 20 sering terasa baik-baik saja. Masalah mulai terlihat ketika:

  • Latency naik pada halaman dalam: halaman 1 cepat, halaman 200 jauh lebih lambat.
  • Beban database meningkat: CPU, I/O, atau buffer hit naik walaupun jumlah item per halaman tetap.
  • Hasil tidak stabil: item bisa terlewat atau muncul ganda jika ada insert/delete di antara permintaan halaman.
  • Resolver GraphQL tampak lambat: padahal penyebab utamanya ada di query SQL, bukan di serialisasi respons.

Misalnya daftar posts diurutkan berdasarkan waktu publikasi terbaru. Query halaman awal terlihat ringan, tetapi halaman ke-500 memerlukan database untuk memindai dan membuang ribuan baris sebelum mengambil 20 baris berikutnya.

Mengapa OFFSET mahal di level SQL

Query sebelum optimasi

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

Secara logis query ini sederhana. Namun secara fisik, database tetap harus menemukan urutan hasil, lalu melewati 10.000 baris terlebih dahulu sebelum mengembalikan 20 baris. Walaupun ada index yang membantu pengurutan, OFFSET besar tetap berarti pekerjaan tambahan karena baris-baris awal masih harus diproses untuk dilewati.

Inilah alasan mengapa page depth menjadi mahal. Biaya tidak proporsional terhadap ukuran halaman, tetapi terhadap posisi halaman. Halaman 1 mungkin murah, halaman 1000 jauh lebih mahal.

Masalah konsistensi hasil

OFFSET pagination juga rentan terhadap pergeseran data. Contohnya:

  1. Klien meminta halaman 1.
  2. Sebelum meminta halaman 2, ada 3 baris baru yang masuk di urutan paling atas.
  3. OFFSET 20 pada halaman 2 sekarang merujuk ke posisi yang berbeda dari sebelumnya.

Akibatnya, item bisa muncul dua kali atau justru terlewat. Untuk feed yang terus berubah, ini bukan sekadar isu performa, tetapi juga isu integritas pengalaman data.

Kapan beralih ke GraphQL Cursor Pagination

Berikut tanda bahwa Anda sebaiknya beralih:

  • Daftar diurutkan berdasarkan kolom yang monoton atau semi-monoton, seperti created_at, published_at, atau id.
  • Klien lebih sering melakukan navigasi maju (load more, infinite scroll) daripada lompat ke halaman acak.
  • Data sering berubah karena insert/update/delete.
  • Halaman dalam mulai memicu query lambat.
  • Anda ingin hasil yang lebih stabil antar permintaan.

Sebaliknya, OFFSET masih dapat diterima untuk dataset kecil, tampilan admin dengan navigasi halaman acak, atau query analitik yang tidak sensitif terhadap perubahan urutan data. Masalahnya bukan bahwa OFFSET selalu salah, tetapi bahwa ia tidak cocok untuk daftar besar yang aktif berubah.

Desain cursor yang benar: stabil, unik, dan bisa di-index

Cursor pagination bekerja baik jika urutannya deterministik. Jangan hanya mengurutkan dengan published_at DESC jika banyak baris dapat memiliki nilai waktu yang sama. Tambahkan tie-breaker unik, biasanya id.

Urutan yang aman:

ORDER BY published_at DESC, id DESC

Cursor dapat berisi pasangan (published_at, id) yang dienkode, misalnya ke Base64. Isi cursor tidak harus terenkripsi; yang penting konsisten dan tervalidasi saat dibaca.

Contoh schema GraphQL

type Query {
  posts(first: Int!, after: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type Post {
  id: ID!
  title: String!
  publishedAt: String!
}

Pola ini mirip model connection yang umum dipakai di GraphQL. Keuntungannya, klien tidak lagi bergantung pada nomor halaman, melainkan pada posisi terakhir yang diterima.

SQL sebelum dan sesudah: dari OFFSET ke seek method

Sebelum: OFFSET/LIMIT

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

Sesudah: cursor pagination dengan kondisi WHERE

SELECT id, title, published_at
FROM posts
WHERE status = 'PUBLISHED'
  AND (
    published_at < :cursor_published_at
    OR (published_at = :cursor_published_at AND id < :cursor_id)
  )
ORDER BY published_at DESC, id DESC
LIMIT 20;

Query kedua sering disebut seek pagination. Bedanya mendasar: database tidak diminta melewati ribuan baris, tetapi langsung mencari posisi setelah cursor. Jika index mendukung, biaya query cenderung mengikuti ukuran halaman, bukan kedalaman halaman.

Jika database Anda mendukung perbandingan tuple secara andal, bentuk seperti (published_at, id) < (:published_at, :id) bisa lebih ringkas. Namun bentuk eksplisit dengan OR sering lebih mudah dipahami dan lebih portabel.

Index yang tepat untuk WHERE + ORDER BY

Cursor pagination tidak otomatis cepat tanpa index yang sesuai. Prinsip dasarnya:

  • Kolom filter dengan selektivitas penting diletakkan di depan jika memang selalu dipakai.
  • Kolom urut dan tie-breaker harus mengikuti pola ORDER BY.
  • Urutan kolom pada index harus mencerminkan cara query mencari dan mengurutkan data.

Contoh pola query

WHERE status = 'PUBLISHED'
ORDER BY published_at DESC, id DESC

Index yang umumnya masuk akal:

CREATE INDEX idx_posts_status_published_at_id
ON posts (status, published_at DESC, id DESC);

Mengapa bentuk ini membantu:

  • status dipakai sebagai filter tetap.
  • published_at adalah kunci urutan utama.
  • id menjadi tie-breaker unik untuk menjaga urutan stabil.

Dengan index seperti ini, database punya peluang lebih besar untuk memenuhi WHERE dan ORDER BY dari jalur index yang sama, lalu berhenti setelah menemukan LIMIT baris.

Kesalahan index yang sering terjadi

  • Hanya meng-index kolom filter, misalnya (status), sehingga database tetap perlu sort tambahan.
  • Hanya meng-index kolom urut, misalnya (published_at), padahal filter utama ada di status.
  • Tidak menambahkan tie-breaker unik, sehingga urutan tidak deterministik.
  • Mengubah query tetapi lupa menyesuaikan index, misalnya menambah filter tenant atau kategori tetapi masih memakai index lama.

Jika ada multi-tenant atau filter tambahan

Misalnya query selalu dibatasi oleh tenant_id:

SELECT id, title, published_at
FROM posts
WHERE tenant_id = :tenant_id
  AND status = 'PUBLISHED'
  AND (
    published_at < :cursor_published_at
    OR (published_at = :cursor_published_at AND id < :cursor_id)
  )
ORDER BY published_at DESC, id DESC
LIMIT 20;

Maka index perlu disesuaikan dengan pola akses nyata, misalnya:

CREATE INDEX idx_posts_tenant_status_published_at_id
ON posts (tenant_id, status, published_at DESC, id DESC);

Jangan menebak dari teori saja. Lihat query produksi yang paling sering dan paling mahal, lalu cocokkan index dengan kombinasi filter dan urutannya.

Membaca EXPLAIN secara ringkas

Anda tidak perlu menjadi pakar planner untuk mendeteksi masalah utama. Saat membandingkan query OFFSET dan cursor, fokus pada hal berikut:

  • Apakah ada sort tambahan? Jika ya, query mungkin tidak memanfaatkan urutan index.
  • Berapa banyak baris yang harus dibaca dibanding yang dikembalikan? Semakin besar selisihnya, semakin boros.
  • Apakah planner memakai index scan/range scan yang relevan? Ini biasanya pertanda baik untuk cursor pagination.
  • Apakah biaya meningkat tajam saat OFFSET membesar? Itu sinyal klasik.

Interpretasi praktis

Pada query OFFSET, Anda sering melihat planner tetap harus memproses banyak baris sebelum mencapai posisi target. Pada query cursor yang baik, planner cenderung melakukan scan terarah dari posisi cursor lalu berhenti cepat setelah LIMIT terpenuhi.

Jika setelah migrasi query masih lambat, biasanya penyebabnya salah satu dari ini:

  • Index belum cocok dengan pola WHERE + ORDER BY.
  • Kolom urutan tidak cukup selektif tanpa tie-breaker.
  • Filter dinamis terlalu banyak sehingga satu index tidak bisa optimal untuk semua kombinasi.
  • Resolver GraphQL masih memicu N+1 query setelah daftar utama berhasil diambil.

Contoh resolver GraphQL yang praktis

Berikut contoh resolver pseudo-code yang mengambil first + 1 baris untuk menghitung hasNextPage tanpa query hitung terpisah:

function encodeCursor(row) {
  return base64(JSON.stringify({
    publishedAt: row.published_at,
    id: row.id
  }));
}

function decodeCursor(cursor) {
  return JSON.parse(base64Decode(cursor));
}

async function postsResolver(_, { first, after }, { db }) {
  const limit = Math.min(first, 100);
  const params = { limit: limit + 1 };

  let whereCursor = "";
  if (after) {
    const c = decodeCursor(after);
    params.cursor_published_at = c.publishedAt;
    params.cursor_id = c.id;
    whereCursor = `
      AND (
        published_at < :cursor_published_at
        OR (published_at = :cursor_published_at AND id < :cursor_id)
      )`;
  }

  const rows = await db.query(`
    SELECT id, title, published_at
    FROM posts
    WHERE status = 'PUBLISHED'
    ${whereCursor}
    ORDER BY published_at DESC, id DESC
    LIMIT :limit
  `, params);

  const hasNextPage = rows.length > limit;
  const items = hasNextPage ? rows.slice(0, limit) : rows;

  return {
    edges: items.map(row => ({
      node: {
        id: row.id,
        title: row.title,
        publishedAt: row.published_at
      },
      cursor: encodeCursor(row)
    })),
    pageInfo: {
      hasNextPage,
      endCursor: items.length ? encodeCursor(items[items.length - 1]) : null
    }
  };
}

Poin penting dari implementasi ini:

  • Cursor dibangun dari kolom urut aktual, bukan dari nomor halaman.
  • Ambil satu baris ekstra untuk mengetahui apakah masih ada halaman berikutnya.
  • Batasi first agar klien tidak meminta ribuan item sekaligus.

Trade-off implementasi yang perlu dipahami

Kelebihan cursor pagination

  • Lebih stabil saat data berubah.
  • Lebih efisien untuk halaman dalam.
  • Lebih cocok untuk infinite scroll dan feed real-time.

Kekurangan dan batasannya

  • Tidak ideal untuk lompat ke halaman acak. Cursor menjawab pertanyaan “lanjut dari sini”, bukan “langsung ke halaman 500”.
  • Lebih rumit di sisi klien karena klien harus menyimpan cursor.
  • Urutan harus benar-benar stabil. Jika Anda mengurutkan berdasarkan skor yang sering berubah, hasil antar permintaan tetap bisa bergeser.
  • Backward pagination (before/last) butuh perhatian tambahan pada logika arah urutan dan pembalikan hasil.

Jika produk Anda masih membutuhkan nomor halaman untuk UI tertentu, Anda bisa menjalankan dua mode: cursor untuk feed utama, offset untuk tampilan administratif yang terbatas. Pilih berdasarkan pola akses, bukan berdasarkan satu solusi untuk semua kasus.

Kesalahan umum saat migrasi dari OFFSET

  • Masih menyimpan konsep page number di belakang layar lalu mengonversinya ke offset besar. Ini tidak menyelesaikan masalah SQL.
  • Cursor hanya berisi id padahal urutan utama berdasarkan created_at atau published_at.
  • Tidak memvalidasi cursor, sehingga input rusak memicu error SQL atau hasil aneh.
  • Lupa menyamakan filter antara permintaan awal dan permintaan berikutnya. Cursor hanya valid dalam konteks filter dan urutan yang sama.
  • Mengabaikan N+1 query. Daftar utama cepat, tetapi pengambilan relasi per item tetap membuat keseluruhan resolver lambat.

Checklist migrasi aman tanpa merusak klien lama

  1. Identifikasi query yang paling mahal dari log database, APM, atau slow query log.
  2. Tentukan urutan yang stabil, misalnya published_at DESC, id DESC.
  3. Buat index yang sesuai dengan pola WHERE + ORDER BY.
  4. Tambahkan field GraphQL baru, misalnya postsConnection atau parameter after/first, tanpa langsung menghapus mode lama.
  5. Implementasikan dual-read atau rollout bertahap untuk klien tertentu bila perlu.
  6. Uji konsistensi hasil saat ada insert/delete di tengah proses paginasi.
  7. Bandingkan EXPLAIN dan latency sebelum/sesudah pada query representatif.
  8. Pasang batas maksimum page size di resolver.
  9. Monitor error cursor decode, anomali hasil ganda, dan perubahan pola beban database.
  10. Depresiasi endpoint atau argumen berbasis offset secara bertahap setelah mayoritas klien berpindah.

Strategi kompatibilitas untuk klien lama

Jika klien lama masih memakai page dan perPage, jangan memutus kontrak secara mendadak. Beberapa strategi yang lebih aman:

  • Tambahkan field baru untuk cursor pagination, biarkan field lama tetap hidup sementara.
  • Dokumentasikan bahwa field lama cocok hanya untuk dataset kecil atau halaman awal.
  • Gunakan telemetry per klien untuk melihat siapa yang masih memakai offset.
  • Set tanggal deprecation setelah klien penting selesai migrasi.

Mencoba menyulap API lama berbasis nomor halaman menjadi cursor secara diam-diam biasanya membingungkan klien. Lebih baik eksplisit dan bertahap.

Langkah debugging saat query masih lambat setelah pindah ke cursor

  • Pastikan kolom pada cursor sama dengan kolom ORDER BY.
  • Periksa apakah filter tambahan seperti tenant_id, category_id, atau visibility sudah masuk ke index yang relevan.
  • Lihat apakah planner masih melakukan sort besar atau scan terlalu banyak baris.
  • Pastikan arah perbandingan sesuai dengan arah urutan: untuk DESC, kondisi umumnya memakai < pada item setelah cursor.
  • Audit resolver lanjutan dan pemuatan relasi untuk mencegah N+1.
  • Uji dengan data yang cukup besar; dataset kecil sering menyamarkan masalah planner.

Kesimpulan

Ketika query GraphQL melambat karena pagination berbasis OFFSET, akar masalahnya biasanya adalah SQL yang harus membaca dan membuang makin banyak baris seiring halaman bertambah dalam. GraphQL Cursor Pagination memperbaiki dua hal sekaligus: performa pada daftar besar dan stabilitas hasil saat data berubah.

Kunci keberhasilannya bukan hanya mengganti argumen API, tetapi juga memilih urutan yang deterministik dan merancang index yang mengikuti pola WHERE + ORDER BY. Jika Anda melihat latency naik pada halaman dalam, hasil pagination bergeser, dan database bekerja terlalu keras untuk mengembalikan sedikit baris, itu saatnya berhenti mengandalkan OFFSET dan beralih ke cursor yang benar.