Pagination adalah cara membagi data dalam jumlah besar menjadi beberapa bagian kecil. Hampir semua aplikasi web membutuhkannya: daftar produk, riwayat transaksi, komentar, hingga dashboard laporan.
Masalahnya, pagination yang berjalan baik saat tabel masih berisi ratusan baris belum tentu tetap efisien ketika datanya sudah jutaan. Query yang lambat, hasil yang meloncat, dan tombol “halaman berikutnya” yang membingungkan biasanya muncul karena pagination dirancang hanya dari sisi tampilan, bukan dari cara database bekerja.
Offset pagination: mudah dipahami, tetapi punya batas
Pendekatan paling umum menggunakan parameter page dan per_page. Misalnya, halaman kedua dengan 20 data ditulis seperti ini:
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;MySQL memang mendukung LIMIT dan OFFSET untuk mengambil sebagian hasil. Cara ini cocok untuk halaman admin, laporan sederhana, atau data yang tidak terlalu besar. Dokumentasi MySQL juga menjelaskan bahwa LIMIT membantu database mengambil hanya jumlah baris yang diperlukan, terutama jika urutan data dapat dibantu oleh indeks.
Namun, offset besar tetap memiliki biaya. Untuk mengambil halaman ke-5.000, database perlu melewati banyak baris lebih dulu sebelum mengembalikan data yang diminta. Akibatnya, halaman awal terasa cepat, sementara halaman jauh di belakang mulai lambat.
Pastikan urutan data benar-benar stabil
Pagination akan bermasalah jika query tidak memiliki urutan yang jelas. Jangan hanya menulis ORDER BY created_at DESC jika beberapa baris bisa memiliki waktu pembuatan yang sama.
Tambahkan kolom unik sebagai pengikat, biasanya id:
ORDER BY created_at DESC, id DESCDengan begitu, dua artikel yang dibuat pada detik yang sama tetap memiliki posisi yang dapat ditentukan. Tanpa pengikat unik, urutan baris yang memiliki nilai sama dapat berubah antarpermintaan. Pengguna bisa melihat data yang sama dua kali atau melewatkan data tertentu saat berpindah halaman.
Ini bukan sekadar persoalan estetika. Urutan yang konsisten adalah fondasi pagination yang dapat dipercaya.
Cursor pagination untuk data yang terus bergerak
Untuk feed, log aktivitas, notifikasi, atau daftar transaksi yang terus bertambah, cursor pagination sering lebih tepat. Alih-alih meminta “halaman ke-berapa”, client meminta data setelah item terakhir yang sudah diterima.
Misalnya, respons sebelumnya berakhir pada:
created_at = '2026-08-27 10:15:00'
id = 845Query berikutnya dapat mencari baris yang posisinya berada setelah pasangan tersebut:
SELECT id, title, created_at
FROM articles
WHERE (created_at < :created_at)
OR (created_at = :created_at AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;Dalam praktiknya, cursor dapat dikemas menjadi token seperti eyJjcmVhdGVkX2F0Ijoi.... Client tidak perlu mengetahui detail struktur internal cursor tersebut. Server cukup mengembalikan next_cursor untuk permintaan berikutnya.
Keuntungan utamanya adalah performa yang lebih stabil pada data besar, selama kolom yang dipakai untuk pencarian dan pengurutan memiliki indeks yang sesuai. Kekurangannya, pengguna tidak dapat langsung melompat ke halaman 100 dan fitur “total halaman” menjadi lebih sulit dihitung.
Contoh implementasi dengan PHP PDO
Gunakan prepared statement ketika nilai berasal dari pengguna. Prepared statement memisahkan template SQL dari nilai parameter dan membantu mengurangi risiko SQL injection. Dokumentasi PHP juga mengingatkan bahwa placeholder hanya dapat dipakai untuk nilai data, bukan nama tabel, nama kolom, atau bagian sintaks SQL lainnya.
<?php
$limit = min(max((int)($_GET['limit'] ?? 20), 1), 100);
$cursorCreatedAt = $_GET['created_at'] ?? null;
$cursorId = isset($_GET['id']) ? (int) $_GET['id'] : null;
$sql = "SELECT id, title, created_at
FROM articles";
$params = [];
if ($cursorCreatedAt !== null && $cursorId !== null) {
$sql .= " WHERE (created_at < :created_at)
OR (created_at = :created_at AND id < :id)";
$params['created_at'] = $cursorCreatedAt;
$params['id'] = $cursorId;
}
$sql .= " ORDER BY created_at DESC, id DESC
LIMIT " . $limit;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
$response = [
'data' => $items,
'next_cursor' => count($items) === $limit
? end($items)
: null
];Contoh tersebut masih perlu disesuaikan dengan format token yang digunakan aplikasi. Untuk proyek produksi, sebaiknya jangan mengirim seluruh nilai cursor mentah jika nilai itu dapat dimanipulasi atau membingungkan client. Cursor bisa diubah menjadi token yang di-encode dan, bila perlu, ditandatangani.
Indeks harus mengikuti pola query
Query pagination tidak cukup hanya diberi LIMIT. Database juga perlu indeks yang mendukung kondisi dan urutannya. Untuk contoh di atas, indeks gabungan dapat menjadi titik awal:
CREATE INDEX idx_articles_created_id
ON articles (created_at DESC, id DESC);Dukungan indeks tetap perlu diperiksa dengan data nyata. Jalankan EXPLAIN untuk melihat apakah MySQL memilih indeks yang diharapkan atau masih melakukan pemindaian dan pengurutan besar-besaran. Dokumentasi MySQL menyarankan penggunaan EXPLAIN untuk memeriksa rencana eksekusi, termasuk bagaimana database menangani ORDER BY.
Jangan membuat terlalu banyak indeks tanpa alasan. Setiap indeks membantu pembacaan, tetapi menambah pekerjaan ketika data baru dimasukkan, diubah, atau dihapus.
Validasi parameter dari client
Parameter pagination datang dari URL atau request body, sehingga tidak boleh langsung dipercaya. Batasi nilai limit, tetapkan nilai default, dan tolak cursor yang formatnya salah.
- Gunakan batas maksimal, misalnya 100 baris per permintaan.
- Pastikan ID berupa bilangan bulat positif.
- Validasi format tanggal sebelum dipakai dalam query.
- Jangan mengizinkan client menentukan nama kolom
ORDER BYsecara bebas. - Gunakan whitelist jika fitur pengurutan memang diperlukan.
Prepared statement melindungi nilai parameter, tetapi tidak otomatis membuat seluruh query aman. Jika nama kolom atau arah pengurutan dibangun dari input pengguna, bagian tersebut harus diperiksa secara terpisah.
Rancang kontrak respons yang jelas
Frontend akan lebih mudah bekerja jika format respons konsisten. Contoh sederhana:
{
"data": [
{
"id": 845,
"title": "Artikel Pertama",
"created_at": "2026-08-27 10:15:00"
}
],
"next_cursor": "...",
"has_more": true
}Untuk offset pagination, Anda mungkin menambahkan page, per_page, dan total. Untuk cursor pagination, has_more dan next_cursor biasanya lebih berguna daripada menghitung total seluruh data pada setiap permintaan.
Apa artinya bagi kita?
Offset pagination bukan pilihan yang salah. Ia sederhana, mudah diuji, dan cocok untuk banyak halaman administrasi. Masalah muncul ketika pola tersebut diterapkan pada feed besar yang sering berubah tanpa mempertimbangkan indeks dan kestabilan urutan.
Mulailah dari kebutuhan produk: apakah pengguna perlu melompat ke halaman tertentu, atau hanya perlu memuat data berikutnya? Jika yang kedua lebih penting, cursor pagination layak dipertimbangkan.
Yang bisa dilakukan sekarang
- Periksa semua endpoint yang memakai
LIMITdanOFFSET. - Tambahkan urutan kedua menggunakan kolom unik seperti
id. - Jalankan
EXPLAINpada query dengan data yang mendekati kondisi produksi. - Batasi ukuran halaman agar client tidak meminta ribuan baris sekaligus.
- Gunakan prepared statement untuk nilai dari pengguna.
- Pilih cursor pagination untuk feed atau data besar yang terus berubah.
Pagination yang baik bukan hanya membuat daftar terlihat rapi. Ia menentukan seberapa ringan database bekerja, seberapa konsisten data diterima pengguna, dan seberapa mudah API dipelihara ketika aplikasi mulai tumbuh.
Sumber & bacaan lebih lanjut
- MySQL 8.4 Reference Manual: LIMIT Query Optimization
- MySQL 8.4 Reference Manual: ORDER BY Optimization
- MySQL 8.4 Reference Manual: Optimizing Queries with EXPLAIN
- PHP Manual: Prepared statements and stored procedures
- PHP Manual: PDO::prepare
– Rio Yotto @rioyotto
