Pagination #
Hampir setiap aplikasi punya halaman daftar dengan pagination — daftar order, log aktivitas, daftar produk, riwayat transaksi. Pola paling umum yang dipakai adalah LIMIT dan OFFSET: ambil 20 baris, skip sekian baris. Di halaman pertama semuanya terasa cepat. Di halaman ke-5 masih oke. Tapi di halaman ke-500, ketika OFFSET sudah 10.000, tiba-tiba query terasa berat — dan pengguna yang paling rajin scroll atau developer yang paling sering mengekspor data ke halaman terakhir adalah yang pertama merasakannya. Masalahnya bukan di LIMIT — LIMIT efisien. Masalahnya ada di OFFSET, yang cara kerjanya di database jauh lebih mahal daripada yang terlihat. Artikel ini membahas mengapa OFFSET bermasalah secara fundamental, dua pendekatan pagination yang lebih scalable, dan bagaimana mendesain query pagination yang tetap cepat meskipun data sudah jutaan baris.
Mengapa OFFSET Makin Besar Makin Lambat #
Sebagian besar developer mengira OFFSET 10000 LIMIT 20 berarti “lompat ke baris ke-10.000, ambil 20 baris”. Ini adalah miskonsepsi yang sangat umum. Yang sebenarnya terjadi jauh lebih mahal:
-- Query pagination halaman 500 (20 item per halaman)
SELECT id, title, price, created_at
FROM products
WHERE category_id = 5
ORDER BY created_at DESC
LIMIT 20 OFFSET 9980;
-- Yang terjadi di database:
-- 1. Scan index (category_id, created_at) dari awal
-- 2. Baca dan proses 10.000 baris pertama yang memenuhi WHERE
-- 3. Buang 9.980 baris pertama (OFFSET)
-- 4. Kembalikan 20 baris sisanya (LIMIT)
-- Database tidak bisa "lompat" ke baris ke-9.981.
-- Ia harus melewati semua baris sebelumnya.
Visualisasi biaya OFFSET yang bertambah:
Biaya query OFFSET di tabel products (500.000 baris):
──────────────────────────────────────────────────────────────
OFFSET 0 LIMIT 20 → baca 20 baris → ~1ms
OFFSET 200 LIMIT 20 → baca 220 baris → ~2ms
OFFSET 2000 LIMIT 20 → baca 2.020 baris → ~8ms
OFFSET 10000 LIMIT 20 → baca 10.020 baris → ~35ms
OFFSET 50000 LIMIT 20 → baca 50.020 baris → ~180ms
OFFSET 200000 LIMIT 20 → baca 200.020 baris → ~700ms
──────────────────────────────────────────────────────────────
Setiap "halaman berikutnya" makin mahal.
Halaman ke-500 memakan 700× lebih banyak resource daripada halaman 1.
Biaya = O(offset + limit), bukan O(limit).
Output EXPLAIN untuk OFFSET besar juga mengungkap masalahnya:
EXPLAIN SELECT id, title, price, created_at
FROM products
WHERE category_id = 5
ORDER BY created_at DESC
LIMIT 20 OFFSET 9980;
-- +-------+----------------------------+-------+--------------------+
-- | type | key | rows | Extra |
-- +-------+----------------------------+-------+--------------------+
-- | range | idx_products_cat_created | 10000 | Using index cond. |
-- +-------+----------------------------+-------+--------------------+
-- rows = 10.000 → database membaca 10.000 baris untuk mengembalikan 20
Offset-Based Pagination: Kelebihan dan Kelemahan Fundamental #
Meskipun OFFSET punya masalah performa di halaman jauh, ia punya kelebihan yang membuatnya tetap relevan untuk konteks tertentu.
Offset-Based Pagination (LIMIT + OFFSET):
──────────────────────────────────────────────────────────────
Kelebihan:
✓ Bisa lompat ke halaman mana saja secara acak
("Ke halaman 47" langsung tanpa navigasi urut)
✓ Total halaman bisa dihitung (jika COUNT tersedia)
✓ Familiar bagi user — "Halaman 1 dari 23"
✓ Mudah diimplementasikan di backend dan frontend
✓ Bisa sinkronisasi URL (?page=5)
Kelemahan:
✗ Biaya O(offset + limit) — semakin dalam semakin lambat
✗ Data bisa bergeser saat ada insert/delete
(user di halaman 2 mungkin sudah pindah ke halaman 1
saat ada data baru yang ditambahkan)
✗ Tidak cocok untuk data yang sering berubah
✗ Tidak scalable untuk deep pagination (halaman > 100)
Kapan masih tepat:
✓ Dataset kecil (< 10.000 baris)
✓ Halaman admin dengan filter yang membatasi result secara signifikan
✓ User memang butuh akses ke halaman arbitrer ("ke halaman 47")
✓ Data statis atau jarang berubah
──────────────────────────────────────────────────────────────
Mitigasi Masalah OFFSET dengan Keyed Pagination (Deferred Join) #
Ada teknik untuk membuat offset-based pagination lebih efisien tanpa mengubah UX secara drastis: alih-alih membaca semua kolom sekaligus, gunakan subquery atau JOIN untuk hanya membaca ID dulu (via index), lalu fetch baris lengkapnya berdasarkan ID tersebut.
-- ANTI-PATTERN: baca semua kolom langsung dengan OFFSET besar
SELECT id, title, description, price, stock, image_url, created_at
FROM products
WHERE category_id = 5
ORDER BY created_at DESC
LIMIT 20 OFFSET 9980;
-- → Baca 10.000 baris lengkap (termasuk semua kolom) lalu buang 9.980
-- BENAR: deferred join — ambil ID dulu via index, fetch data lengkap kemudian
SELECT p.id, p.title, p.description, p.price, p.stock, p.image_url, p.created_at
FROM (
SELECT id
FROM products
WHERE category_id = 5
ORDER BY created_at DESC
LIMIT 20 OFFSET 9980
) AS paged
JOIN products p ON paged.id = p.id;
-- → Subquery: baca 10.000 ID saja dari index (jauh lebih kecil)
-- → JOIN: fetch hanya 20 baris lengkap berdasarkan 20 ID yang terpilih
-- → Total I/O jauh lebih kecil jika baris punya banyak kolom besar
Deferred join tidak menghilangkan masalah OFFSET fundamental (tetap O(offset)), tapi mengurangi volume data yang dibaca secara signifikan pada tabel dengan banyak kolom atau kolom besar.
Cursor-Based Pagination: Scalable di Semua Kedalaman #
Cursor-based pagination (juga disebut keyed pagination atau seek pagination) adalah pendekatan yang menggantikan OFFSET dengan filter berbasis nilai dari baris terakhir yang sudah dilihat. Alih-alih “skip 9.980 baris”, database langsung melanjutkan dari titik yang tepat di index.
Cara Kerjanya #
-- Halaman pertama: tidak ada cursor
SELECT id, title, price, created_at
FROM products
WHERE category_id = 5
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- → Kembalikan 20 baris pertama
-- → Catat nilai terakhir: created_at = '2026-01-15 10:30:00', id = 8421
-- Halaman berikutnya: gunakan nilai terakhir sebagai cursor
SELECT id, title, price, created_at
FROM products
WHERE category_id = 5
AND (created_at < '2026-01-15 10:30:00'
OR (created_at = '2026-01-15 10:30:00' AND id < 8421))
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- → Database langsung seek ke titik (created_at='2026-01-15 10:30:00', id=8421)
-- di index (category_id, created_at DESC, id DESC)
-- → Tidak ada OFFSET, tidak ada "baca lalu buang"
-- → Biaya tetap O(limit), berapapun "halamannya"
Mengapa id disertakan dalam cursor dan ORDER BY? Karena created_at bisa bernilai sama untuk beberapa baris — tanpa tiebreaker, pagination bisa melewatkan atau menduplikasi baris di batas halaman.
Perbandingan biaya cursor-based vs offset-based:
──────────────────────────────────────────────────────────────
Tabel products: 500.000 baris, filter category_id = 5 → 80.000 baris
"Halaman 500" (item ke-9.981 s.d. 10.000):
Offset-based:
→ OFFSET 9980 LIMIT 20
→ Baca 10.000 baris, buang 9.980
→ ~180ms
Cursor-based:
→ WHERE created_at < [cursor] AND id < [cursor_id]
→ Database seek langsung ke posisi cursor di index
→ Baca tepat 20 baris
→ ~1ms
Perbedaan: 180× lebih lambat untuk offset-based di kedalaman ini.
Di halaman ke-5.000: perbedaannya bisa 1.000× atau lebih.
──────────────────────────────────────────────────────────────
Index yang Diperlukan untuk Cursor-Based Pagination #
Cursor-based pagination hanya efisien jika kolom yang dipakai sebagai cursor ter-index dengan benar, mencakup semua filter WHERE sekaligus kolom cursor:
-- Query pagination dengan filter status dan cursor created_at + id
SELECT id, title, price, status, created_at
FROM orders
WHERE user_id = 42
AND status = 'paid'
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Index yang dibutuhkan:
CREATE INDEX idx_orders_cursor
ON orders (user_id, status, created_at DESC, id DESC);
-- → Semua kolom WHERE + ORDER BY/cursor tercakup dalam satu index
-- → Query bisa menggunakan index untuk seek langsung ke posisi cursor
Implementasi Lengkap: Cursor-Based Pagination di Go #
Berikut implementasi cursor-based pagination yang lengkap, termasuk encoding cursor, decoding, dan response format yang umum dipakai di REST API:
// Cursor menyimpan dua nilai: created_at dan id dari baris terakhir
type PageCursor struct {
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
}
// Encode cursor ke string Base64 untuk dikirim ke client
func encodeCursor(c PageCursor) string {
data, _ := json.Marshal(c)
return base64.StdEncoding.EncodeToString(data)
}
// Decode cursor dari string Base64 yang diterima dari client
func decodeCursor(s string) (*PageCursor, error) {
data, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, err
}
var c PageCursor
if err := json.Unmarshal(data, &c); err != nil {
return nil, err
}
return &c, nil
}
type Order struct {
ID int64
UserID int64
Total float64
Status string
CreatedAt time.Time
}
type OrderPage struct {
Data []Order `json:"data"`
NextCursor string `json:"next_cursor,omitempty"` // kosong jika halaman terakhir
HasMore bool `json:"has_more"`
}
func GetUserOrders(ctx context.Context, userID int64, status string, cursorStr string, limit int) (*OrderPage, error) {
var args []interface{}
var query string
if cursorStr == "" {
// Halaman pertama — tidak ada cursor
query = `
SELECT id, user_id, total, status, created_at
FROM orders
WHERE user_id = ? AND status = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
`
args = []interface{}{userID, status, limit + 1}
} else {
// Halaman berikutnya — decode dan gunakan cursor
cursor, err := decodeCursor(cursorStr)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
query = `
SELECT id, user_id, total, status, created_at
FROM orders
WHERE user_id = ?
AND status = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT ?
`
args = []interface{}{
userID, status,
cursor.CreatedAt, cursor.CreatedAt, cursor.ID,
limit + 1,
}
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var orders []Order
for rows.Next() {
var o Order
rows.Scan(&o.ID, &o.UserID, &o.Total, &o.Status, &o.CreatedAt)
orders = append(orders, o)
}
// Cek apakah ada halaman berikutnya (ambil limit+1, cek sisanya)
hasMore := len(orders) > limit
if hasMore {
orders = orders[:limit]
}
// Buat cursor untuk halaman berikutnya dari baris terakhir
var nextCursor string
if hasMore {
last := orders[len(orders)-1]
nextCursor = encodeCursor(PageCursor{
CreatedAt: last.CreatedAt,
ID: last.ID,
})
}
return &OrderPage{
Data: orders,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
}
Response API-nya:
{
"data": [...],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6ODQyMX0=",
"has_more": true
}
Client mengirim next_cursor sebagai parameter query di request berikutnya: GET /api/orders?cursor=eyJj...
Cursor yang di-encode ke Base64 tidak dienkripsi — user bisa men-decode dan melihat nilainya. Jika nilai cursor mengandung informasi sensitif (misalnya ID internal), pertimbangkan untuk mengenkripsi cursor menggunakan HMAC atau AES sebelum dikirim ke client.
Pagination dengan Filter dan Sort yang Bervariasi #
Salah satu tantangan cursor-based pagination adalah ketika user bisa memilih kolom sort yang berbeda-beda. Cursor harus mengandung nilai dari kolom yang sedang dijadikan basis sort.
-- Skenario: user bisa sort berdasarkan price atau created_at
-- Sort by price:
SELECT id, title, price, created_at
FROM products
WHERE category_id = ?
AND (price > ? OR (price = ? AND id > ?)) -- cursor: price + id
ORDER BY price ASC, id ASC
LIMIT 20;
-- Cursor: {price: 149900, id: 5821}
-- Sort by created_at:
SELECT id, title, price, created_at
FROM products
WHERE category_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?)) -- cursor: created_at + id
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Cursor: {created_at: "2026-01-15T10:30:00Z", id: 8421}
Setiap kombinasi sort membutuhkan index yang berbeda. Ini adalah trade-off cursor-based: lebih banyak kolom sort = lebih banyak index yang perlu dibuat dan dikelola.
Index yang dibutuhkan untuk tiap sort option:
──────────────────────────────────────────────────────────────
Sort by created_at DESC:
→ INDEX (category_id, created_at DESC, id DESC)
Sort by price ASC:
→ INDEX (category_id, price ASC, id ASC)
Sort by name ASC:
→ INDEX (category_id, name ASC, id ASC)
Jika terlalu banyak sort option:
→ Pertimbangkan offset-based untuk halaman awal (1-10)
→ Batasi sort option ke 2-3 yang paling sering dipakai
→ Atau terima bahwa cursor-based tidak cocok untuk use case ini
──────────────────────────────────────────────────────────────
Pagination di Admin Panel: Panduan Khusus #
Admin panel punya karakteristik berbeda dari endpoint publik — akses lebih jarang, filter lebih kompleks, dan kebutuhan akses ke halaman tertentu lebih sering. Ini mempengaruhi pilihan strategi pagination.
Masalah Double COUNT yang Sering Tidak Disadari #
Admin panel modern yang menggunakan library seperti React Admin, Ant Design Table, atau DataTables sering melakukan dua query COUNT secara diam-diam:
Request daftar order admin (tanpa disadari mengeksekusi):
──────────────────────────────────────────────────────────────
1. SELECT COUNT(*) FROM orders WHERE status = 'paid'
→ Untuk menampilkan "12.483 records"
→ Dieksekusi oleh ORM/library pagination
2. SELECT COUNT(*) FROM orders WHERE status = 'paid'
→ Untuk validasi apakah page number yang diminta valid
→ Dieksekusi lagi oleh layer validasi
3. SELECT * FROM orders WHERE status = 'paid'
ORDER BY created_at DESC LIMIT 20 OFFSET 40
→ Baru ini yang mengambil data
Tiga query untuk satu halaman — dua di antaranya COUNT mahal.
──────────────────────────────────────────────────────────────
Solusi untuk admin panel:
// Hindari double COUNT — hitung sekali, simpan di response
type AdminOrderListResponse struct {
Data []Order `json:"data"`
TotalCount *int64 `json:"total_count,omitempty"` // optional
HasNextPage bool `json:"has_next_page"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// Strategi: hitung total hanya di halaman 1, cache untuk halaman berikutnya
// Atau: hilangkan total count sama sekali, gunakan LIMIT+1
func GetAdminOrders(page, perPage int, status string) (*AdminOrderListResponse, error) {
offset := (page - 1) * perPage
// Ambil data dengan satu baris ekstra untuk deteksi has_next_page
rows, _ := db.Query(`
SELECT id, user_id, total, status, created_at
FROM orders
WHERE status = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, status, perPage+1, offset)
var orders []Order
for rows.Next() {
var o Order
rows.Scan(&o.ID, &o.UserID, &o.Total, &o.Status, &o.CreatedAt)
orders = append(orders, o)
}
hasNextPage := len(orders) > perPage
if hasNextPage {
orders = orders[:perPage]
}
return &AdminOrderListResponse{
Data: orders,
HasNextPage: hasNextPage,
Page: page,
PerPage: perPage,
// TotalCount dikosongkan — tidak perlu COUNT
}, nil
}
Batasi Kedalaman OFFSET di Admin #
Jika OFFSET tidak bisa dihindari (karena kebutuhan akses ke halaman arbitrer), batasi kedalaman maksimum:
// Tolak request dengan OFFSET yang terlalu besar
const MaxOffset = 10000
func validatePaginationParams(page, perPage int) error {
offset := (page - 1) * perPage
if offset > MaxOffset {
return fmt.Errorf("halaman terlalu dalam — maksimum halaman %d untuk %d item per halaman",
MaxOffset/perPage+1, perPage)
}
if perPage > 100 {
return fmt.Errorf("maksimum %d item per halaman", 100)
}
return nil
}
Perbandingan Offset-Based vs Cursor-Based #
Matriks perbandingan dua metode pagination:
──────────────────────────────────────────────────────────────────────
Aspek │ Offset-Based │ Cursor-Based
──────────────────────────────────────────────────────────────────────
Performa halaman awal │ Cepat │ Cepat
Performa halaman dalam │ Makin lambat │ Tetap cepat (O(limit))
Akses ke halaman arbitrer│ ✓ Bisa │ ✗ Tidak bisa
Stabilitas saat data │ ✗ Bisa geser/ │ ✓ Stabil
berubah │ duplikat │
Implementasi backend │ Mudah │ Sedang
Implementasi frontend │ Mudah │ Sedang (hanya prev/next)
URL yang bookmarkable │ ✓ (?page=5) │ ✗ (cursor tidak permanen)
Cocok untuk infinite │ ✗ Tidak │ ✓ Sangat cocok
scroll / load more │ │
Kebutuhan index │ Standard │ Composite index spesifik
──────────────────────────────────────────────────────────────────────
Panduan memilih:
Gunakan Offset-Based jika:
✓ Dataset kecil atau pagination tidak lebih dari ~20 halaman
✓ User butuh akses ke halaman tertentu secara langsung
✓ URL yang bisa di-bookmark atau dibagikan
✓ Data relatif statis
Gunakan Cursor-Based jika:
✓ Dataset besar (> 100.000 baris)
✓ Feed, activity stream, atau infinite scroll
✓ Data berubah sering (insert/delete aktif)
✓ Kedalaman halaman tidak dibatasi
✓ API mobile atau real-time yang perlu konsisten
Anti-Pattern yang Harus Dihindari #
-- ✗ Anti-pattern 1: OFFSET sangat besar tanpa batasan
SELECT * FROM logs ORDER BY created_at DESC LIMIT 20 OFFSET 500000;
-- → Baca 500.020 baris, buang 500.000, kembalikan 20
-- ✓ Solusi: cursor-based, atau batasi OFFSET maksimum di aplikasi
-- ✗ Anti-pattern 2: tidak ada ORDER BY dalam pagination
SELECT * FROM products WHERE category_id = 5 LIMIT 20 OFFSET 40;
-- → Urutan tidak deterministik — baris yang sama bisa muncul di halaman berbeda
-- ✓ Solusi: selalu sertakan ORDER BY dengan kolom yang unik (misal: id)
-- ✗ Anti-pattern 3: ORDER BY kolom tanpa index yang tepat
SELECT * FROM orders WHERE user_id = 42 ORDER BY total DESC LIMIT 20;
-- Jika tidak ada index (user_id, total): filesort di seluruh baris user 42
-- ✓ Solusi: CREATE INDEX idx_orders_user_total ON orders (user_id, total DESC)
-- ✗ Anti-pattern 4: cursor dari kolom yang tidak unique dan tidak monoton
-- Misalnya cursor hanya dari kolom status (banyak duplikat)
WHERE status > ? ORDER BY status
-- → Banyak baris dengan status yang sama → baris terlewat atau duplikat
-- ✓ Solusi: selalu gunakan tiebreaker yang unique (id) sebagai cursor sekunder
-- ✗ Anti-pattern 5: per_page tanpa batas maksimum
GET /api/products?per_page=100000
-- → User meminta semua data sekaligus via pagination
-- → Bisa menguras memory dan koneksi database
-- ✓ Solusi: batasi per_page di server (misal maksimum 100), ignore nilai di atas itu
Checklist Desain Pagination #
SAAT IMPLEMENTASI PAGINATION BARU:
□ Sudah ada ORDER BY yang deterministik (termasuk kolom unique)?
□ Semua kolom di ORDER BY ter-index bersama kolom WHERE?
□ Ada batas maksimum per_page di server?
□ OFFSET dibatasi atau diganti cursor-based?
□ Apakah butuh total count? Bisa pakai LIMIT+1 saja?
□ Sudah diverifikasi dengan EXPLAIN (tidak ada filesort)?
UNTUK CURSOR-BASED:
□ Cursor mengandung semua kolom yang diperlukan untuk seek?
□ Ada tiebreaker unique (id) dalam cursor?
□ Cursor di-encode (Base64 atau enkripsi) sebelum dikirim ke client?
□ Ada validasi cursor yang invalid atau kadaluarsa?
□ Index mencakup (WHERE columns, ORDER BY/cursor columns)?
UNTUK OFFSET-BASED:
□ Dataset kecil atau halaman dibatasi secara wajar?
□ Ada batasan OFFSET maksimum di server?
□ Digunakan deferred join jika baris punya banyak kolom besar?
□ Jika ada COUNT: bisa di-cache atau pakai approximate count?
Ringkasan #
- OFFSET bukan “lompat ke baris ke-N” — database harus membaca dan membuang semua baris sebelum OFFSET. Biayanya O(offset + limit), bukan O(limit). Makin dalam halaman, makin lambat.
- Cursor-based pagination selalu O(limit) — berapapun “halamannya”, biaya tetap sama karena database seek langsung ke posisi cursor di index. Ini adalah solusi untuk deep pagination.
- ORDER BY tanpa tiebreaker unique menyebabkan data geser atau duplikat — selalu sertakan kolom unique (biasanya
id) sebagai kolom terakhir di ORDER BY dan sebagai bagian dari cursor.- Composite index harus mencakup kolom WHERE sekaligus kolom ORDER BY/cursor — tanpa ini cursor-based pagination tidak lebih cepat dari offset-based.
- Deferred join mengurangi biaya I/O di offset-based — fetch ID dulu via index, baru JOIN untuk kolom lengkap. Bisa 2-5× lebih efisien untuk tabel dengan banyak kolom besar.
- Double COUNT di admin panel sering tidak disadari — ORM dan library frontend pagination sering mengirimkan dua query COUNT per request. Audit dan ganti dengan LIMIT+1 atau cache.
- Batasi per_page dan OFFSET maksimum di server — jangan percayakan validasi ini ke client. User atau penyerang bisa meminta per_page=1000000 atau OFFSET=99999999.
- Cursor-based tidak cocok untuk semua kasus — jika user butuh lompat ke halaman tertentu atau URL pagination harus bisa di-bookmark, offset-based dengan dataset yang dibatasi masih pilihan yang tepat.