Skip to main content
BlogWeb Dev

Cursor vs Offset Pagination: Kesenjangan Performa 17x di Skala

Mengapa offset pagination rusak pada dataset besar dan cara mengimplementasikan cursor pagination dalam API NestJS yang tetap cepat di kedalaman halaman mana pun.

Matthews Wong

Software Engineer

Juni 202510 menit baca
Web Dev

OFFSET 10000 memaksa PostgreSQL untuk memindai dan membuang 10.000 baris sebelum mengembalikan hasil. Pada halaman 1 (OFFSET 0), kueri Anda membutuhkan 10ms. Pada halaman 1.000 (OFFSET 10.000), kueri yang sama pada tabel 1 juta baris dapat membutuhkan 5.000ms. Benchmark menunjukkan cursor pagination 17x lebih cepat dari offset untuk halaman yang dalam pada dataset besar.

DimensiOffset PaginationCursor Pagination
Performa query di halaman dalamMenurun linear, halaman ke-1.000 pada 1 juta baris bisa memakan waktu detikWaktu konstan berapa pun dalamnya halaman
Kompleksitas implementasiSepele: LIMIT dan OFFSETButuh sort key yang stabil dan encoding cursor opaque
Akses halaman acakBisa langsung loncat ke nomor halaman mana punHanya mendukung next atau previous, tidak bisa loncat sembarang halaman
Konsistensi saat ada penulisanBaris bisa bergeser antar halaman kalau data berubah saat scrollHasil tetap stabil walau ada baris yang ditambah atau dihapus
Tampilan total countMudah: COUNT bersamaan dengan queryButuh query count terpisah, sering dilewati demi performa
Paling cocok untukTabel admin dengan nomor halaman dan dataset kecilInfinite scroll dan dataset besar seperti riwayat transaksi

Mengapa Offset Pagination Lambat

Klausul OFFSET SQL memberi tahu database untuk melewati N baris sebelum mengembalikan hasil. Untuk melakukan ini, PostgreSQL harus mengidentifikasi N baris pertama, membuangnya, lalu mengembalikan M baris berikutnya. Seiring bertumbuhnya OFFSET, begitu pula pekerjaannya. Ini adalah O(OFFSET) — biaya tumbuh secara linear dengan seberapa dalam Anda ke dalam dataset.

Masalah Konsistensi Data

Offset pagination memiliki masalah kedua: konsistensi data. Jika Anda berada di halaman 5 dari daftar faktur dan faktur baru dimasukkan di bagian atas urutan sort, ketika Anda meminta halaman 6, Anda akan melihat duplikat dari faktur terakhir dari halaman 5 atau melewatkan faktur. Dalam sistem penulisan tinggi seperti ERP, ini berarti pengguna yang beraginasi melalui riwayat transaksi bisa mendapatkan tampilan yang tidak konsisten.

Cara Kerja Cursor Pagination

Cursor pagination menggantikan OFFSET dengan klausa WHERE yang memfilter berdasarkan nilai terakhir yang dilihat. Alih-alih 'beri saya baris 10.001 hingga 10.010', Anda mengatakan 'beri saya 10 baris di mana created_at < $lastCursor'. Ini adalah kueri rentang pada kolom yang diindeks — O(log N) terlepas dari seberapa banyak halaman yang Anda masuki. Cursor adalah token buram (biasanya nilai base64-encoded dari sort key) yang dikirim klien dengan setiap permintaan.

Offset Pagination Performance (1M row table):
─────────────────────────────────────────────────
Page 1    (OFFSET 0):        ~10ms  ✓
Page 10   (OFFSET 90):       ~12ms  ✓
Page 100  (OFFSET 900):      ~40ms  ⚠
Page 1000 (OFFSET 9,000):    ~400ms ✗
Page 5000 (OFFSET 49,000):  ~2000ms ✗ TIMEOUT RISK

Cursor Pagination Performance (1M row table):
─────────────────────────────────────────────────
Any page depth:               ~5ms  ✓ O(log N)
Page 1:   WHERE TRUE LIMIT 10           → index scan start
Page 1000: WHERE (created_at, id) < ($1, $2) LIMIT 10
           → index seek to cursor position, then 10 rows

SQL comparison:
  Offset:  SELECT * FROM invoices ORDER BY created_at DESC LIMIT 10 OFFSET 9900
           ↑ must traverse 9,900 rows to find start

  Cursor:  SELECT * FROM invoices
           WHERE (created_at, id) < ('2024-03-15 10:23:44', 1234)
           ORDER BY created_at DESC, id DESC LIMIT 10
           ↑ seeks directly to cursor position in index

Dari pengalaman saya mengimplementasikan cursor pagination di API ERP: gunakan cursor komposit saat mengurutkan berdasarkan kolom non-unik seperti `created_at`. Jika dua faktur dibuat pada milidetik yang persis sama, cursor satu bidang akan ambigu dan halaman bisa tumpang tindih atau melewatkan rekaman. Komposisikan cursor dari `(created_at, id)` — urutkan berdasarkan `created_at DESC, id DESC`, encode kedua nilai ke dalam cursor, dan filter dengan `WHERE (created_at, id) < ($cursorDate, $cursorId)`. PostgreSQL mendukung perbandingan nilai-baris pada indeks, membuat ini efisien.

Mengimplementasikan Cursor Pagination di NestJS

Implementasi NestJS melibatkan utilitas encoder/decoder cursor, query builder yang dimodifikasi, dan bentuk respons yang terstandarisasi. Respons mengembalikan items (halaman saat ini), nextCursor (null jika tidak ada lagi halaman), dan hasMore (boolean). Klien menyimpan cursor dan mengirimkannya pada permintaan berikutnya.

Enkoding Cursor

Nilai cursor harus buram bagi klien — ini adalah detail implementasi. Encode nilai mentah sebagai base64 JSON: `Buffer.from(JSON.stringify({created_at: lastItem.createdAt, id: lastItem.id})).toString('base64url')`. Decode dan validasi di server sebelum digunakan dalam kueri SQL — selalu validasi nilai cursor untuk mencegah SQL injection.

// cursor-pagination.util.ts
export function encodeCursor(createdAt: Date, id: number): string {
  return Buffer.from(
    JSON.stringify({ created_at: createdAt.toISOString(), id })
  ).toString('base64url')
}

export function decodeCursor(cursor: string): { created_at: string; id: number } {
  try {
    return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'))
  } catch {
    throw new Error('Invalid cursor')
  }
}

// invoices.service.ts — NestJS cursor pagination
@Injectable()
export class InvoicesService {
  constructor(private readonly dataSource: DataSource) {}

  async findAll(limit: number, cursor?: string) {
    const take = Math.min(limit, 100) // cap page size
    let cursorWhere = ''
    const params: unknown[] = [take + 1] // fetch +1 to detect hasMore

    if (cursor) {
      const { created_at, id } = decodeCursor(cursor)
      cursorWhere = 'AND (created_at, id) < ($2, $3)'
      params.push(created_at, id)
    }

    const rows = await this.dataSource.query(
      `SELECT id, invoice_number, amount, created_at
       FROM invoices
       WHERE 1=1 ${cursorWhere}
       ORDER BY created_at DESC, id DESC
       LIMIT $1`,
      params
    )

    const hasMore = rows.length > take
    const items = hasMore ? rows.slice(0, take) : rows
    const lastItem = items[items.length - 1]

    return {
      items,
      hasMore,
      nextCursor: hasMore && lastItem
        ? encodeCursor(new Date(lastItem.created_at), lastItem.id)
        : null,
    }
  }
}

Cursor Dua Arah

Cursor pagination standar adalah satu arah — Anda hanya bisa maju. Untuk tombol 'halaman sebelumnya', implementasikan cursor dua arah: pertahankan nextCursor dan prevCursor dalam setiap respons. prevCursor berasal dari item pertama di halaman saat ini daripada yang terakhir. Ini menggandakan kompleksitas implementasi Anda tetapi memungkinkan navigasi prev/next penuh sambil mempertahankan performa O(log N) di kedua arah.

Trade-off utama cursor pagination adalah Anda tidak dapat langsung melompat ke halaman 50 — Anda harus beraginasi secara berurutan. Untuk sebagian besar antarmuka pengguna (infinite scroll, tombol 'load more', navigasi next/previous), ini sempurna. Tetapi untuk UI admin dengan input nomor halaman, pengguna mengharapkan untuk mengetik '50' dan melompat ke halaman 50. Jika produk Anda memerlukan lompatan halaman sembarang, Anda memerlukan offset pagination (menerima biaya performa), pendekatan hybrid, atau offset yang didenormalisasi untuk kasus penggunaan tertentu.

Cursor Pagination di GraphQL

Relay Connection Specification GraphQL mendefinisikan pola cursor pagination yang terstandarisasi: tipe `Connection` dengan `edges` (array dari `{node, cursor}`), `pageInfo` ({hasPreviousPage, hasNextPage, startCursor, endCursor}), dan `totalCount`. Mengimplementasikan ini di NestJS dengan @nestjs/graphql memberi API Anda antarmuka standar yang dapat bekerja dengan klien GraphQL mana pun.

Desain Indeks Database untuk Kueri Cursor

Cursor pagination hanya cepat jika kolom sort Anda memiliki indeks. Untuk `ORDER BY created_at DESC, id DESC`, buat indeks komposit: `CREATE INDEX idx_invoices_created_id ON invoices (created_at DESC, id DESC)`. PostgreSQL dapat menggunakan indeks ini untuk ORDER BY maupun filter cursor WHERE, menghilangkan sequential scan sepenuhnya.

Sumber & Bacaan Lebih Lanjut

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa offset pagination sangat lambat pada dataset besar?

Klausul OFFSET SQL memaksa PostgreSQL untuk memindai dan membuang N baris pertama sebelum mengembalikan hasil, sehingga biayanya tumbuh secara linear seiring kedalaman halaman — O(OFFSET). Pada tabel 1 juta baris, kueri yang membutuhkan 10ms di halaman 1 bisa membutuhkan 5.000ms di halaman 1.000. Bahkan dengan indeks B-tree pada kolom sort, database tetap harus menelusuri indeks dari awal untuk mencapai posisi offset.

Bagaimana cursor pagination mencapai performa 17x lebih baik?

Cursor pagination menggantikan OFFSET dengan klausa WHERE yang memfilter berdasarkan nilai terakhir yang dilihat, mengubah pemindaian linear menjadi kueri rentang indeks O(log N). Misalnya, alih-alih melewati 10.000 baris, kueri meminta baris di mana created_at lebih kecil dari nilai cursor terakhir. Pendekatan ini terbukti 17x lebih cepat dari offset pagination untuk halaman yang dalam pada dataset besar.

Mengapa harus menggunakan cursor komposit daripada cursor satu bidang?

Jika Anda mengurutkan berdasarkan kolom non-unik seperti created_at, dua rekaman yang dibuat pada milidetik yang persis sama akan menghasilkan cursor yang ambigu, menyebabkan halaman tumpang tindih atau melewatkan rekaman. Mengkomposisikan cursor dari (created_at, id) dan memfilter dengan WHERE (created_at, id) < ($cursorDate, $cursorId) menjamin paginasi yang stabil dan tidak ambigu. PostgreSQL mendukung perbandingan nilai-baris pada indeks komposit, sehingga tetap efisien.

Apa trade-off utama cursor pagination dibandingkan offset?

Cursor pagination tidak mendukung lompatan ke halaman sembarang — Anda harus beraginasi secara berurutan. Untuk sebagian besar antarmuka seperti infinite scroll atau tombol next/previous hal ini tidak masalah, tetapi UI admin yang memungkinkan pengguna mengetik nomor halaman membutuhkan pendekatan berbeda. Post ini merekomendasikan pendekatan hybrid: gunakan cursor pagination untuk navigasi next/previous berurutan dan offset hanya untuk input nomor halaman.

Bagaimana nilai cursor sebaiknya di-encode dan divalidasi?

Nilai cursor harus bersifat buram bagi klien, di-encode sebagai base64url JSON yang berisi nilai sort key mentah (misalnya created_at dan id dari item terakhir). Penggunaan base64url menghindari karakter padding yang dapat menyebabkan masalah di query string. Di server, selalu decode dan validasi nilai cursor sebelum digunakan dalam kueri SQL untuk mencegah SQL injection, serta tangani kasus di mana cursor menunjuk ke rekaman yang sudah dihapus dengan mengembalikan halaman kosong secara graceful.