DEBI PRAHARADIKA
← Back to Blog Index
Backend Architecture2026-08-1525 min read

Kelemahan OFFSET vs Keunggulan Keyset/Cursor pada API Pagination

Paginasi API Anda makin lambat saat pengguna berpindah ke halaman 1000? Temukan alasan mengapa OFFSET adalah kelemahan besar pada arsitektur Anda dan beralihlah ke arsitektur Keyset/Cursor Pagination.

"Using OFFSET for pagination is like throwing away the first 10,000 pages of a dictionary just to read page 10,001." — Markus Winand, SQL Performance Expert

Membagi data menjadi potongan-potongan kecil (paginasi) adalah sebuah pola yang sangat umum ditemukan saat merancang Application Programming Interface (API). Pada fase awal Software Development, sebagian besar developer cenderung merancang paginasi mereka menggunakan LIMIT dan OFFSET karena sintaksnya yang sangat mudah, misalnya /api/users?page=3&limit=50.

Tetapi tahukah Anda bahwa pola tersebut adalah masalahan teknis yang tersembunyi di arsitektur Anda? Saat database membesar hingga menyentuh angka puluhan juta baris, pendekatan OFFSET akan menurunkan kinerja database secara perlahan.

Pada artikel ini, kami akan membahas mengenai kelemahan OFFSET dibandingkan dengan Keyset/Cursor Pagination. Selain itu, kami juga akan memberikan contoh implementasi menggunakan Golang.


1. Kelemahan OFFSET Pagination

Untuk memahami batas kemampuan OFFSET, kita harus mengetahui bagaimana sistem database memproses kueri di low-level. Saat sistem menerima kueri dengan parameter OFFSET dan LIMIT, sistem akan membaca, mengurutkan, dan memuat data dalam jumlah besar sesuai dengan OFFSET, lalu membuang data yang tidak diperlukan sebelum mengembalikan hasilnya kepada Anda.

Perbandingan Arsitektur Database: OFFSET Pagination yang memindai baris secara sekuensial vs Keyset Pagination yang melompat langsung via indeks

Ketika aplikasi Anda mengirimkan kueri seperti:

SELECT * FROM orders ORDER BY created_at DESC LIMIT 50 OFFSET 100000;

Mungkin bayangan anda adalah database akan langsung melompat ke baris ke-100.001. Ternyata, hal itu tidak terjadi. Database relasional (seperti PostgreSQL atau MySQL) harus membaca, mengurutkan, dan memuat 100.050 baris ke dalam memori server, lalu membuang 100.000 baris pertama dan menampilkan 50 baris terakhir kepada Anda. Inilah yang menyebabkan kinerja database menurun seiring bertambahnya data. Dan inilah kelemahan utama dari OFFSET:

  1. Performa yang Terdegradasi (O(N)): Semakin dalam halaman yang diakses (misal: halaman 10.000), kueri akan memakan waktu eksponensial lebih lama serta membebani CPU dan RAM database.
  2. Anomali Data (Duplikasi/Hilang): Jika pengguna berada di Halaman 1, lalu sebuah data baru dimasukkan, saat pengguna menekan tombol "Halaman 2", mereka akan melihat data yang sama berulang kali (data shifting).

2. Keunggulan Keyset/Cursor Pagination

Kami menyarankan pendekatan Cursor / Keyset Pagination. Daripada mengatakan kepada database, "Beri saya 50 baris setelah melewati 100.000 baris", dan mengubah instruksinya menjadi "Beri saya 50 baris di mana ID-nya lebih lama dari xyz123".

Kueri SQL-nya menjadi lebih sederhana:

SELECT * FROM orders 
WHERE id < 997621 -- "997621" adalah nilai kursor dari baris terakhir di layar pengguna
ORDER BY id DESC 
LIMIT 50;

Kelebihan Utama:

  1. Performa Konstan (O(1)): Selama kolom penunjuk (seperti id atau created_at) memiliki indeks (B-Tree Index), database dapat melompat ke baris target dalam hitungan kurang dari 1 milidetik, tidak peduli apakah itu halaman ke-1 atau halaman ke-10.000.
  2. Kekebalan Terhadap Mutasi Data: Penambahan atau penghapusan baris baru yang terjadi secara seketika (real-time) di latar belakang tidak akan merusak urutan kursor, sehingga pengguna tidak akan pernah mengalami duplikasi atau kehilangan pandangan terhadap data.

3. Implementasi Koding dan Deployment

Di perusahaan, kami menerapkan standar arsitektur Cursor Pagination menggunakan Golang (Go) dalam menangani konkurensi dan pemrosesan data masif. Berikut adalah kerangka kodenya:

package handlers

import (
	"database/sql"
	"fmt"
)

// Struktur response ke frontend
type PaginatedResponse struct {
	Data       []Order `json:"data"`
	NextCursor *string `json:"next_cursor"`
}

type Order struct {
	ID        int     `json:"id"`
	Total     float64 `json:"total"`
	CreatedAt string  `json:"created_at"`
}

// Handler API: GET /api/v1/orders?cursor=base64string&limit=50
func FetchOrders(db *sql.DB, cursorStr string, limit int) (*PaginatedResponse, error) {
	query := `SELECT id, total, created_at FROM orders`
	var params []interface{}

	// Jika pengguna mengirimkan kursor, dekode dan gunakan dalam klausa WHERE
	if cursorStr != "" {
		decodedID := DecodeCursor(cursorStr)
		query += ` WHERE id < ?`
		params = append(params, decodedID)
	}

	// Mengambil N + 1 data untuk mengecek apakah masih ada halaman selanjutnya
	query += ` ORDER BY id DESC LIMIT ?`
	params = append(params, limit+1)

	rows, err := db.Query(query, params...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var orders []Order
	for rows.Next() {
		var o Order
		if err := rows.Scan(&o.ID, &o.Total, &o.CreatedAt); err != nil {
			return nil, err
		}
		orders = append(orders, o)
	}

	var nextCursor *string

	// Jika data yang didapat melebihi limit, berarti ada halaman selanjutnya
	if len(orders) > limit {
		// Ambil elemen terakhir yang asli sebagai penunjuk kursor baru
		lastValidRow := orders[limit-1]
		encoded := EncodeCursor(fmt.Sprintf("%d", lastValidRow.ID))
		nextCursor = &encoded

		// Buang data ekstra (+1) yang kita tarik hanya untuk pengujian
		orders = orders[:limit]
	}

	return &PaginatedResponse{
		Data:       orders,
		NextCursor: nextCursor, // Dikembalikan ke frontend untuk request selanjutnya
	}, nil
}

Tips Eksekusi: Selalu sandikan (encode) kursor Anda (misalnya ke dalam Base64) sebelum mengirimnya ke sisi antarmuka klien (frontend). Ini mencegah klien memanipulasi logika internal struktur data Anda (seperti mengubah tipe ID secara sewenang-wenang).


4. Antarmuka Aplikasi: Infinite Scrolling

Secara estetika dan fungsionalitas, Cursor Pagination adalah fondasi dibalik interaksi antarmuka aplikasi bergaya Infinite Scrolling (Gulir Tanpa Batas) yang mendominasi aplikasi mobile modern.

UI Infinite Scrolling pada aplikasi Mobile Feed yang sedang melakukan request API dengan payload kursor untuk mengambil bongkahan data berikutnya

Setiap kali pengguna menggulir layar mendekati dasar, aplikasi akan memanggil API dengan menyematkan kursor rahasia yang diterimanya dari muatan response sebelumnya. Desain UX ini sangat halus dan bebas dari jeda berlarut yang sering ditemui pada tombol angka tradisional (1, 2, 3 ...).


Menggunakan OFFSET untuk paginasi ibarat melakukan full table scan secara klandestin. Ini adalah Technical Debt yang bisa memperlambat server Anda seiring dengan masifnya pertumbuhan data.

Beralihlah menggunakan strategi Cursor / Keyset Pagination. Penerapan arsitektur yang melompat via indeks B-Tree tidak hanya bisa mempercepat proses, tetapi juga memberikan pengalaman real-time tanpa gangguan duplikasi bagi pengguna di aplikasi.

Tetap semangat belajar, bereksplorasi, dan jangan pernah berhenti membangun hal-hal yang luar biasa!