API-DESIGN PAGINATION BACKEND-DEVELOPMENT FRONTEND-DEVELOPMENT SYSTEM-DESIGN WEB-PERFORMANCE DATA-FETCHING USER-EXPERIENCE DATABASE-PERFORMANCE REST-API

Strategi Pagination API yang Efektif: Offset vs Cursor dan Implikasinya untuk Frontend & Backend

⏱️ 10 menit baca
👨‍💻

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:

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:

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

❌ Kekurangan

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

❌ Kekurangan

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?

🎯 Kapan Menggunakan Cursor-Based Pagination?

💡 Pendekatan Hybrid

Dalam beberapa kasus, Anda bisa menggabungkan kedua pendekatan:

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