Strategi Pagination API yang Efektif: Offset vs Cursor dan Implikasinya untuk Frontend & Backend
Pernahkah Anda membuka sebuah daftar panjang di aplikasi, lalu melihat tombol “Next Page” atau “Load More”? Itu adalah pagination, mekanisme krusial untuk mengelola dan menampilkan data dalam jumlah besar di aplikasi web. Tanpa pagination, browser akan kewalahan memuat ribuan (atau jutaan!) data sekaligus, membuat aplikasi menjadi lambat bahkan crash.
Sebagai developer, kita sering dihadapkan pada pilihan: bagaimana cara terbaik mengimplementasikan pagination di API kita? Dua strategi utama yang paling umum adalah Offset-Based Pagination dan Cursor-Based Pagination. Keduanya memiliki kelebihan dan kekurangan yang signifikan, dan pilihan yang tepat akan sangat memengaruhi performa API, pengalaman pengguna, serta kompleksitas pengembangan di sisi frontend maupun backend.
Mari kita selami lebih dalam kedua strategi ini dan kapan sebaiknya Anda menggunakannya.
1. Pendahuluan
Bayangkan Anda memiliki toko online dengan jutaan produk. Jika semua produk dimuat sekaligus saat pengguna membuka halaman, website Anda pasti akan sangat lambat. Di sinilah pagination berperan: ia memecah daftar panjang tersebut menjadi “halaman” atau “segmen” yang lebih kecil, sehingga hanya sebagian data yang dimuat dan ditampilkan pada satu waktu.
Artikel ini akan membahas secara tuntas:
- Bagaimana Offset-Based Pagination bekerja, kelebihan, dan kekurangannya.
- Bagaimana Cursor-Based Pagination bekerja, kelebihan, dan kekurangannya.
- Kapan harus memilih salah satu strategi berdasarkan use case Anda.
- Implikasi praktis untuk implementasi di backend dan frontend.
Mari kita mulai!
2. Offset-Based Pagination (Limit/Offset)
Offset-based pagination adalah metode yang paling intuitif dan mungkin paling sering Anda temui. Konsepnya mirip dengan bagaimana kita membaca buku: kita tahu ada halaman 1, halaman 2, dan seterusnya.
📌 Cara Kerjanya
Strategi ini mengandalkan dua parameter utama:
limit: Berapa banyak item yang ingin diambil per halaman.offset: Berapa banyak item yang harus dilewati dari awal daftar.
Misalnya, untuk mengambil 10 item pertama, Anda akan menggunakan limit=10 dan offset=0. Untuk 10 item berikutnya (halaman kedua), Anda akan menggunakan limit=10 dan offset=10.
Contoh Request API:
GET /api/products?limit=10&offset=0 // Halaman 1
GET /api/products?limit=10&offset=10 // Halaman 2
GET /api/products?limit=10&offset=20 // Halaman 3
Contoh Query SQL (PostgreSQL):
SELECT * FROM products
ORDER BY id ASC
LIMIT 10 OFFSET 0;
SELECT * FROM products
ORDER BY id ASC
LIMIT 10 OFFSET 10;
✅ Kelebihan
- Sederhana dan Mudah Dipahami: Konsep
limitdanoffsetsangat mudah dimengerti, baik oleh developer backend maupun frontend. - Navigasi Halaman Arbitrer: Pengguna dapat dengan mudah melompat ke halaman mana pun (misalnya, langsung ke halaman 50) asalkan nomor halaman dan total item diketahui. Ini bagus untuk UI pagination tradisional dengan nomor halaman.
- Total Count Mudah Dihitung: Biasanya, API akan mengembalikan
totalCountatautotalItemsbersama dengan data, yang memungkinkan frontend membangun UI pagination lengkap dengan jumlah halaman.
❌ Kekurangan
- Performa Menurun pada Offset Besar: Ini adalah kekurangan terbesar. Ketika
offsetmenjadi sangat besar (misalnya,OFFSET 100000), database harus memindai (atau setidaknya memproses) 100.000 record pertama sebelum dapat mengambil 10 record yang diminta. Ini bisa sangat lambat dan memakan banyak sumber daya. - Inkonsistensi Data (Phantom Reads): ⚠️ Bayangkan Anda mengambil halaman 1 (item 1-10). Kemudian, sebelum Anda mengambil halaman 2, ada item baru yang ditambahkan di awal daftar. Saat Anda mengambil halaman 2 (
offset=10), Anda mungkin akan melihat item yang sama dengan item terakhir di halaman 1, atau bahkan melewatkan beberapa item. Ini menyebabkan pengalaman pengguna yang membingungkan.- Analogi: Seperti membaca buku telepon. Jika Anda sedang membaca halaman 5, dan tiba-tiba ada orang baru yang pindah ke kota dan namanya dimasukkan di halaman 1, maka semua “halaman” berikutnya akan bergeser. Saat Anda melanjutkan ke halaman 6, Anda mungkin membaca ulang entri yang sudah Anda baca di halaman 5, atau melewatkan entri baru yang seharusnya ada di halaman 5 yang baru.
- Tidak Ideal untuk “Infinite Scroll”: Karena masalah inkonsistensi data, offset-based pagination tidak cocok untuk pola infinite scroll di mana pengguna terus memuat data baru tanpa nomor halaman eksplisit.
3. Cursor-Based Pagination (Keyset Pagination)
Cursor-based pagination, sering disebut juga Keyset Pagination, menawarkan solusi untuk masalah performa dan inkonsistensi data pada offset yang besar. Konsepnya lebih mirip dengan membaca feed media sosial: Anda memuat beberapa postingan, dan ketika Anda menggulir ke bawah, Anda memuat postingan yang setelah postingan terakhir yang Anda lihat.
📌 Cara Kerjanya
Strategi ini tidak menggunakan nomor halaman atau offset. Sebagai gantinya, API akan mengembalikan sebuah “cursor” (biasanya nilai dari kolom yang diurutkan, seperti ID unik atau timestamp) yang menunjukkan posisi item terakhir yang diambil. Untuk mengambil data berikutnya, frontend mengirimkan cursor ini sebagai penanda.
Contoh Request API:
GET /api/products?limit=10 // Ambil 10 item pertama
// Response: ... data produk ..., "nextCursor": "product_id_10"
GET /api/products?limit=10&after=product_id_10 // Ambil 10 item setelah product_id_10
// Response: ... data produk ..., "nextCursor": "product_id_20"
Parameter after (atau since, start_key) adalah cursor yang Anda dapatkan dari respons sebelumnya.
Contoh Query SQL (PostgreSQL):
-- Ambil 10 item pertama (tanpa cursor)
SELECT * FROM products
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- Ambil 10 item setelah cursor tertentu
SELECT * FROM products
WHERE (created_at < '2023-10-26 10:00:00' OR (created_at = '2023-10-26 10:00:00' AND id < 12345))
ORDER BY created_at DESC, id DESC
LIMIT 10;
💡 Catatan: Cursor biasanya melibatkan kombinasi kolom (misalnya created_at dan id) untuk memastikan keunikan dan konsistensi, terutama jika ada banyak item dengan created_at yang sama.
✅ Kelebihan
- Performa Konsisten: Performa tidak menurun seiring bertambahnya “halaman” atau jumlah data yang dilewati, karena database langsung mencari dari titik
cursoryang diberikan. Ini menggunakan indeks secara efisien. - Integritas Data Terjaga: Data yang diambil konsisten bahkan jika ada penambahan/penghapusan item di awal daftar. Anda selalu memuat item yang setelah cursor yang valid.
- Ideal untuk “Infinite Scroll”: Sangat cocok untuk aplikasi dengan pola “Load More” atau infinite scroll, karena pengguna tidak perlu melompat ke halaman tertentu, hanya terus memuat data berikutnya.
❌ Kekurangan
- Tidak Bisa Melompat ke Halaman Arbitrer: Pengguna tidak bisa langsung pergi ke halaman 50. Navigasi hanya bisa “maju” atau “mundur” dari posisi saat ini. Ini kurang ideal untuk UI pagination tradisional dengan nomor halaman.
- Membutuhkan Kolom yang Unik dan Terurut: Untuk menjadi efektif, Anda memerlukan kolom yang dapat diurutkan secara konsisten dan idealnya unik (seperti ID atau timestamp unik). Jika ada duplikasi pada kolom
ORDER BY(misalnya, dua item memilikicreated_atyang sama), Anda perlu menambahkan kolom sekunder (sepertiid) untuk memecah tie. - Implementasi Lebih Kompleks: Logic di backend untuk membangun query
WHEREdengan cursor bisa sedikit lebih kompleks daripada sekadarLIMITdanOFFSET. Cursor itu sendiri seringkali perlu di-encode (misalnya, Base64) untuk menyembunyikan detail implementasi atau menggabungkan beberapa kolom. - Total Count Sulit Dihitung: Karena sifatnya yang “relatif”, API cursor-based biasanya tidak mengembalikan
totalCountkarena menghitungnya akan memakan biaya performa yang sama dengan masalah yang ingin dipecahkan oleh cursor-based pagination.
4. Memilih Strategi yang Tepat
Pilihan antara offset dan cursor sangat bergantung pada use case dan kebutuhan performa aplikasi Anda.
🎯 Kapan Menggunakan Offset-Based Pagination?
- Data Set Kecil hingga Menengah: Jika jumlah item yang akan dipaginasi tidak terlalu besar (misalnya, di bawah 10.000 - 100.000 item, tergantung performa database Anda).
- Navigasi Halaman Tradisional: Jika Anda membutuhkan UI pagination dengan nomor halaman eksplisit (Halaman 1, 2, 3…) dan kemampuan untuk melompat ke halaman mana pun.
- Data Tidak Berubah Cepat: Jika urutan atau keberadaan item tidak sering berubah saat pengguna menjelajahi halaman.
🎯 Kapan Menggunakan Cursor-Based Pagination?
- Data Set Sangat Besar: Ini adalah pilihan utama untuk daftar dengan jutaan item atau lebih, di mana performa adalah prioritas utama.
- Infinite Scroll / “Load More”: Sangat cocok untuk pengalaman pengguna seperti feed media sosial atau daftar produk yang terus dimuat saat digulir.
- Data yang Sering Berubah: Jika item sering ditambahkan atau dihapus, cursor-based pagination akan memberikan pengalaman yang lebih konsisten dan bebas duplikasi/lompatan.
- Prioritas Performa dan Konsistensi: Ketika menjaga performa query yang cepat dan memastikan integritas data adalah yang terpenting.
💡 Pendekatan Hybrid
Dalam beberapa kasus, Anda bisa menggabungkan kedua pendekatan:
- Gunakan offset-based pagination untuk beberapa halaman pertama (misalnya, 10 halaman awal) karena performanya masih baik dan memungkinkan navigasi langsung.
- Setelah itu, beralih ke cursor-based pagination untuk navigasi selanjutnya (“Load More” atau “Next Page”) untuk menjaga performa saat
offsetmulai membesar. - Ini bisa menjadi kompromi yang baik jika Anda ingin menawarkan pengalaman navigasi halaman dan juga performa untuk daftar yang sangat panjang.
5. Implementasi Praktis di Backend (Contoh Node.js dengan Express & PostgreSQL)
Mari kita lihat contoh sederhana implementasi untuk kedua strategi.
Offset-Based Pagination
// server.js (contoh sederhana)
const express = require('express');
const { Pool } = require('pg');
const app = express();
const port = 3000;
const pool = new Pool({
user: 'your_user',
host: 'localhost',
database: 'your_db',
password: 'your_password',
port: 5432,
});
app.get('/products/offset', async (req, res) => {
const limit = parseInt(req.query.limit) || 10;
const offset = parseInt(req.query.offset) || 0;
if (limit <= 0 || offset < 0) {
return res.status(400).json({ error: 'Limit and offset must be positive integers.' });
}
try {
// Ambil total count untuk pagination UI
const totalCountResult = await pool.query('SELECT COUNT(*) FROM products');
const totalItems = parseInt(totalCountResult.rows[0].count);
const productsResult = await pool.query(
'SELECT id, name, price FROM products ORDER BY id ASC LIMIT $1 OFFSET $2',
[limit, offset]
);
res.json({
data: productsResult.rows,
totalItems: totalItems,
limit: limit,
offset: offset,
currentPage: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(totalItems / limit)
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
Cursor-Based Pagination
// server.js (lanjutan)
// ... (bagian atas sama)
app.get('/products/cursor', async (req, res) => {
const limit = parseInt(req.query.limit) || 10;
const afterCursor = req.query.after; // Base64 encoded string: 'id,created_at'
if (limit <= 0) {
return res.status(400).json({ error: 'Limit must be a positive integer.' });
}
let cursorCondition = '';
let cursorParams = [];
let currentId = null;
let createdAt = null;
if (afterCursor) {
try {
const decodedCursor = Buffer.from(afterCursor, 'base64').toString('utf8');
[currentId, createdAt] = decodedCursor.split(',');
// Pastikan format tanggal valid
if (isNaN(new Date(createdAt).getTime())) {
throw new Error('Invalid date in cursor');
}
cursorCondition = `WHERE (created_at < $1 OR (created_at = $1 AND id < $2))`;
cursorParams = [createdAt, parseInt(currentId)];
} catch (e) {
return res.status(400).json({ error: 'Invalid cursor provided.' });
}
}
try {
const productsResult = await pool.query(
`SELECT id, name, price, created_at FROM products
${cursorCondition}
ORDER BY created_at DESC, id DESC
LIMIT $${cursorParams.length + 1}`,
[...cursorParams, limit]
);
let nextCursor = null;
if (productsResult.rows.length === limit) {
const lastItem