Saat halaman list di aplikasi makin lambat seiring pertumbuhan data, penyebabnya sering bukan di Go Fiber, melainkan di pola query OFFSET/LIMIT. Semakin besar nilai OFFSET, database biasanya tetap harus membaca, mengurutkan, lalu melewati banyak baris sebelum mengembalikan hasil yang diminta.

Solusi yang umum untuk data besar adalah cursor pagination. Alih-alih berkata “ambil halaman ke-1000”, client berkata “ambil data setelah item terakhir yang saya terima”. Dengan pendekatan ini, query bisa memanfaatkan index secara lebih efisien, latensi lebih stabil, dan risiko performa turun tajam saat data membesar bisa dikurangi.

Masalah OFFSET/LIMIT pada data besar

Pola klasik pagination biasanya terlihat seperti ini:

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20000;

Query di atas tampak sederhana, tetapi saat OFFSET membesar, database tidak otomatis “melompat” langsung ke baris ke-20001. Dalam banyak kasus, database tetap perlu:

  • menentukan urutan hasil sesuai ORDER BY,
  • memindai index atau baris yang relevan,
  • melewati sejumlah besar hasil awal,
  • baru mengambil 20 baris berikutnya.

Gejalanya biasanya terlihat jelas di halaman list:

  • halaman awal cepat, halaman jauh lebih lambat,
  • beban CPU atau I/O database naik saat akses ke halaman dalam,
  • response time tidak konsisten,
  • query list menjadi salah satu top slow query.

Kenapa bottleneck terjadi: scan dan sort

Ada dua sumber bottleneck utama:

  • Scan: database harus membaca banyak entri untuk mencapai offset yang diminta.
  • Sort: jika urutan tidak didukung index yang tepat, database perlu melakukan proses sort tambahan.

Jika query Anda mengurutkan data dengan ORDER BY created_at DESC, id DESC tetapi index tidak sesuai, database bisa memilih rencana yang mahal. Bahkan jika sudah ada index, OFFSET besar tetap membuat database membuang banyak kerja hanya untuk melewati baris-baris awal.

Kapan OFFSET masih layak dipakai?

OFFSET/LIMIT tidak selalu salah. Ada kondisi di mana pendekatan ini masih masuk akal:

  • dataset kecil atau menengah,
  • halaman yang diakses umumnya hanya halaman awal,
  • fitur membutuhkan nomor halaman eksplisit seperti “halaman 3 dari 20”,
  • laporan internal dengan frekuensi akses rendah,
  • query sudah sangat terkontrol dan biaya performanya masih bisa diterima.

Jadi, jangan migrasi hanya karena tren. Migrasi ke cursor pagination paling masuk akal saat:

  • jumlah data terus bertambah,
  • list sering diakses,
  • query menjadi lambat pada offset tinggi,
  • pengalaman pengguna lebih penting daripada nomor halaman absolut.

Prinsip cursor pagination yang benar

Dalam cursor pagination, Anda tidak lagi mengirim page=1000, tetapi mengirim penanda posisi terakhir, misalnya:

GET /posts?limit=20&after=eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xNVQxMDowMDowMFoiLCJpZCI6MTIzNH0=

Cursor tersebut umumnya berisi nilai kolom pengurutan terakhir yang diterima client. Untuk kasus yang umum dan stabil, gunakan kombinasi:

  • created_at sebagai urutan utama,
  • id sebagai tie-breaker.

Mengapa perlu dua kolom? Karena created_at tidak selalu unik. Jika banyak baris memiliki timestamp yang sama, memakai created_at saja berisiko menyebabkan data duplikat atau terlewat. Menambahkan id membuat urutan menjadi deterministik.

Desain urutan yang stabil

Misalnya Anda ingin menampilkan data terbaru lebih dulu:

ORDER BY created_at DESC, id DESC

Maka cursor harus mengikuti urutan yang sama. Untuk mengambil halaman berikutnya setelah item terakhir, kondisi WHERE harus konsisten dengan urutan itu:

WHERE (created_at < $1)
   OR (created_at = $1 AND id < $2)

Ini penting. Banyak implementasi salah hanya memakai id < ? padahal urutannya berdasarkan created_at, id. Hasilnya pagination menjadi tidak stabil.

Skema tabel dan index yang mendukung

Contoh tabel:

CREATE TABLE posts (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

Untuk query cursor pagination di atas, gunakan index komposit yang mengikuti kolom pengurutan:

CREATE INDEX idx_posts_created_id_desc
ON posts (created_at DESC, id DESC);

Beberapa database tetap bisa memanfaatkan index meski arah urutan tidak selalu ditulis persis sama, tetapi prinsip amannya adalah: samakan urutan kolom dan kebutuhan query. Fokus utama bukan hanya ada index, melainkan index yang mendukung WHERE dan ORDER BY sekaligus.

Implementasi endpoint Go Fiber untuk cursor pagination

Berikut contoh endpoint sederhana di Go Fiber. Contoh ini fokus pada alur inti: membaca parameter limit dan after, mendekode cursor, menjalankan query, lalu mengembalikan cursor berikutnya.

package main

import (
  "context"
  "database/sql"
  "encoding/base64"
  "encoding/json"
  "log"
  "strconv"
  "time"

  "github.com/gofiber/fiber/v2"
)

type Post struct {
  ID        int64     `json:"id"`
  Title     string    `json:"title"`
  CreatedAt time.Time `json:"created_at"`
}

type Cursor struct {
  CreatedAt time.Time `json:"created_at"`
  ID        int64     `json:"id"`
}

type ListResponse struct {
  Data       []Post `json:"data"`
  NextCursor string `json:"next_cursor,omitempty"`
  HasMore    bool   `json:"has_more"`
}

func encodeCursor(c Cursor) (string, error) {
  b, err := json.Marshal(c)
  if err != nil {
    return "", err
  }
  return base64.RawURLEncoding.EncodeToString(b), nil
}

func decodeCursor(s string) (*Cursor, error) {
  if s == "" {
    return nil, nil
  }
  b, err := base64.RawURLEncoding.DecodeString(s)
  if err != nil {
    return nil, err
  }
  var c Cursor
  if err := json.Unmarshal(b, &c); err != nil {
    return nil, err
  }
  return &c, nil
}

func listPosts(db *sql.DB) fiber.Handler {
  return func(c *fiber.Ctx) error {
    limit := 20
    if v := c.Query("limit"); v != "" {
      n, err := strconv.Atoi(v)
      if err != nil || n <= 0 {
        return fiber.NewError(fiber.StatusBadRequest, "limit tidak valid")
      }
      if n > 100 {
        n = 100
      }
      limit = n
    }

    cursor, err := decodeCursor(c.Query("after"))
    if err != nil {
      return fiber.NewError(fiber.StatusBadRequest, "cursor tidak valid")
    }

    ctx := context.Background()

    var rows *sql.Rows
    if cursor == nil {
      rows, err = db.QueryContext(ctx, `
        SELECT id, title, created_at
        FROM posts
        ORDER BY created_at DESC, id DESC
        LIMIT ?
      `, limit+1)
    } else {
      rows, err = db.QueryContext(ctx, `
        SELECT id, title, created_at
        FROM posts
        WHERE (created_at < ?)
           OR (created_at = ? AND id < ?)
        ORDER BY created_at DESC, id DESC
        LIMIT ?
      `, cursor.CreatedAt, cursor.CreatedAt, cursor.ID, limit+1)
    }
    if err != nil {
      return fiber.NewError(fiber.StatusInternalServerError, "gagal mengambil data")
    }
    defer rows.Close()

    posts := make([]Post, 0, limit+1)
    for rows.Next() {
      var p Post
      if err := rows.Scan(&p.ID, &p.Title, &p.CreatedAt); err != nil {
        return fiber.NewError(fiber.StatusInternalServerError, "gagal membaca data")
      }
      posts = append(posts, p)
    }
    if err := rows.Err(); err != nil {
      return fiber.NewError(fiber.StatusInternalServerError, "gagal memproses data")
    }

    hasMore := len(posts) > limit
    if hasMore {
      posts = posts[:limit]
    }

    resp := ListResponse{
      Data:    posts,
      HasMore: hasMore,
    }

    if hasMore && len(posts) > 0 {
      last := posts[len(posts)-1]
      next, err := encodeCursor(Cursor{
        CreatedAt: last.CreatedAt,
        ID:        last.ID,
      })
      if err != nil {
        return fiber.NewError(fiber.StatusInternalServerError, "gagal membuat cursor")
      }
      resp.NextCursor = next
    }

    return c.JSON(resp)
  }
}

func main() {
  _ = log.Print
  // inisialisasi db diabaikan agar contoh tetap fokus
}

Beberapa catatan penting dari implementasi di atas:

  • Ambil limit + 1 untuk menentukan apakah masih ada halaman berikutnya.
  • Batasi nilai limit agar client tidak meminta ribuan baris sekaligus.
  • Gunakan base64 JSON sebagai format cursor yang sederhana dan mudah di-debug.
  • Jangan percaya input cursor mentah; selalu validasi dan tangani error decode.

Contoh placeholder query menggunakan tanda ?. Sesuaikan dengan driver SQL yang Anda pakai. Beberapa driver memakai format placeholder berbeda.

Query SQL cursor pagination yang didukung index komposit

Untuk halaman pertama:

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 21;

Untuk halaman berikutnya:

SELECT id, title, created_at
FROM posts
WHERE (created_at < :created_at)
   OR (created_at = :created_at AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT 21;

Pola ini bekerja karena database bisa menavigasi data berdasarkan urutan index, bukan menghitung dan membuang ribuan baris seperti pada OFFSET besar.

Alternatif dengan perbandingan tuple

Beberapa database mendukung perbandingan tuple seperti:

WHERE (created_at, id) < (:created_at, :id)

Ini bisa lebih ringkas, tetapi dukungan dan optimisasinya bergantung pada database yang digunakan. Jika Anda ingin aman lintas implementasi, bentuk eksplisit dengan OR biasanya lebih mudah dipahami dan diverifikasi.

Mencegah duplikasi atau data terlewat saat ada insert baru

Masalah klasik pagination muncul saat data berubah di antara dua request. Misalnya user membuka halaman pertama, lalu ada baris baru di-insert sebelum request halaman berikutnya.

Apa yang terjadi pada OFFSET?

Pada OFFSET pagination, insert baru di bagian atas dapat menggeser posisi data. Akibatnya:

  • beberapa item bisa muncul dua kali,
  • beberapa item bisa terlewat.

Bagaimana cursor membantu?

Cursor pagination jauh lebih stabil karena titik lanjutnya berdasarkan nilai item terakhir yang benar-benar diterima client, bukan nomor halaman. Jika ada insert baru dengan created_at lebih baru, item itu akan muncul saat user melakukan refresh dari awal, bukan menyusup ke tengah alur halaman berikutnya.

Meski begitu, ada beberapa hal yang tetap perlu diperhatikan:

  • Gunakan kolom urutan yang stabil. Jangan gunakan kolom yang sering berubah, seperti updated_at, jika tujuan Anda adalah list yang konsisten.
  • Pakai tie-breaker unik. Kombinasi created_at dan id mengurangi risiko urutan ambigu.
  • Hindari update pada kolom pengurutan setelah data dibuat, jika memungkinkan.

Strategi tambahan untuk konsistensi lebih kuat

Untuk kebutuhan tertentu, Anda bisa menambahkan strategi berikut:

  • Snapshot window: simpan batas atas saat request pertama, misalnya hanya tampilkan item dengan created_at <= snapshot_time selama user masih melanjutkan scrolling.
  • Immutable ordering: urutkan berdasarkan kolom yang tidak berubah setelah insert.
  • Dedup di client: jika ada risiko race condition ringan, client bisa menyimpan set ID yang sudah ditampilkan.

Pendekatan snapshot berguna jika Anda butuh pengalaman “timeline tetap” selama sesi pagination berjalan. Trade-off-nya, user tidak langsung melihat data baru sampai memulai ulang dari halaman pertama.

Cara membaca EXPLAIN secara ringkas

Anda tidak perlu menjadi spesialis query planner untuk mendapat manfaat dari EXPLAIN. Saat membandingkan OFFSET dan cursor pagination, fokus pada beberapa hal berikut:

  • Apakah index yang benar dipakai? Cari tanda bahwa query memanfaatkan index pada kolom pengurutan dan filter.
  • Apakah ada sort tambahan? Jika planner menunjukkan operasi sort yang mahal, index Anda mungkin belum cocok.
  • Berapa banyak baris yang diperkirakan dibaca? Query offset tinggi biasanya menunjukkan pembacaan baris jauh lebih banyak daripada hasil akhir.

Secara praktis, bandingkan dua query:

EXPLAIN SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20000;
EXPLAIN SELECT id, title, created_at
FROM posts
WHERE (created_at < :created_at)
   OR (created_at = :created_at AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Yang ingin Anda lihat pada query cursor adalah rencana yang tetap sempit: memanfaatkan index, membaca lebih sedikit baris, dan menghindari kerja buang-buang akibat offset besar.

Jika hasil EXPLAIN masih buruk meski sudah memakai cursor, periksa lagi apakah urutan kolom index, kondisi WHERE, dan ORDER BY benar-benar selaras.

Trade-off UX dan kompatibilitas API

Trade-off UX

Cursor pagination unggul untuk infinite scroll, feed, activity log, dan list data besar. Namun ada trade-off:

  • sulit mendukung “lompat ke halaman 57”,
  • nomor halaman absolut tidak alami,
  • fitur “total halaman” sering tidak relevan atau mahal dihitung.

Jika produk Anda sangat bergantung pada navigasi halaman bernomor, Anda mungkin perlu mempertahankan OFFSET untuk area tertentu, atau menyediakan dua mode akses yang berbeda sesuai kebutuhan UX.

Kompatibilitas API

Migrasi API tidak selalu harus memutus kompatibilitas. Beberapa pendekatan yang umum:

  • Tambahkan endpoint baru, misalnya /v2/posts dengan cursor pagination.
  • Dukung dua mode sementara: page/per_page dan after/limit.
  • Kembalikan metadata baru seperti next_cursor dan has_more tanpa mengubah struktur item.

Jika Anda menjalankan masa transisi, dokumentasikan prioritas parameter dengan jelas. Misalnya, jika after hadir, abaikan page. Hindari perilaku ambigu.

Checklist migrasi aman dari pagination lama ke cursor pagination

  1. Identifikasi endpoint yang benar-benar bermasalah
    Mulai dari query dengan offset besar yang sering muncul di slow query log.
  2. Tentukan urutan data yang stabil
    Pilih kolom seperti created_at dan tambahkan id sebagai tie-breaker.
  3. Buat index komposit yang sesuai
    Pastikan urutan kolom index mendukung filter dan pengurutan query.
  4. Implementasikan cursor yang aman
    Encode/decode dengan format yang jelas, validasi input, dan jangan expose struktur internal yang tidak perlu.
  5. Ambil limit + 1
    Gunakan untuk menentukan has_more dan membuat next_cursor.
  6. Uji data dengan timestamp kembar
    Pastikan tidak ada duplikasi atau item hilang saat banyak row punya created_at sama.
  7. Uji saat ada insert baru di tengah pagination
    Verifikasi perilaku feed tetap masuk akal untuk UX yang diinginkan.
  8. Bandingkan EXPLAIN sebelum dan sesudah
    Pastikan planner benar-benar memakai index yang diharapkan.
  9. Siapkan masa transisi API
    Pertahankan mode lama sementara jika ada client yang belum bisa migrasi.
  10. Tambahkan observability
    Pantau latency endpoint, error decode cursor, dan pola penggunaan parameter lama vs baru.

Kesalahan umum yang sering terjadi

  • Mengurutkan dengan satu kolom yang tidak unik tanpa tie-breaker.
  • Menggunakan kolom yang berubah-ubah sebagai basis cursor.
  • Index tidak cocok dengan pola WHERE dan ORDER BY.
  • Masih menghitung total count di setiap request padahal tujuan utamanya menurunkan beban query list.
  • Menganggap cursor selalu terenkripsi. Base64 bukan enkripsi; jika cursor memuat data sensitif, pertimbangkan penandatanganan atau tokenisasi.

Penutup

Jika halaman list di aplikasi Go Fiber mulai lambat saat data tumbuh, akar masalahnya sering berasal dari OFFSET/LIMIT yang memaksa database melakukan scan dan sort yang makin mahal. Cursor pagination mengubah pola akses dari “lompat ke halaman tertentu” menjadi “lanjut dari posisi terakhir”, sehingga query lebih ramah index dan performanya lebih stabil pada skala besar.

Kuncinya ada pada desain urutan yang stabil, biasanya created_at plus id, query SQL yang selaras dengan index komposit, serta migrasi API yang hati-hati. OFFSET masih layak untuk kasus sederhana, tetapi untuk feed dan list besar yang terus tumbuh, cursor pagination biasanya menjadi pilihan yang lebih aman dari sisi performa dan konsistensi data.