Klausul OFFSET SQL memaksa PostgreSQL untuk memindai dan membuang N baris pertama sebelum mengembalikan hasil, sehingga biayanya tumbuh secara linear seiring kedalaman halaman — O(OFFSET). Pada tabel 1 juta baris, kueri yang membutuhkan 10ms di halaman 1 bisa membutuhkan 5.000ms di halaman 1.000. Bahkan dengan indeks B-tree pada kolom sort, database tetap harus menelusuri indeks dari awal untuk mencapai posisi offset.
Cursor pagination menggantikan OFFSET dengan klausa WHERE yang memfilter berdasarkan nilai terakhir yang dilihat, mengubah pemindaian linear menjadi kueri rentang indeks O(log N). Misalnya, alih-alih melewati 10.000 baris, kueri meminta baris di mana created_at lebih kecil dari nilai cursor terakhir. Pendekatan ini terbukti 17x lebih cepat dari offset pagination untuk halaman yang dalam pada dataset besar.
Jika Anda mengurutkan berdasarkan kolom non-unik seperti created_at, dua rekaman yang dibuat pada milidetik yang persis sama akan menghasilkan cursor yang ambigu, menyebabkan halaman tumpang tindih atau melewatkan rekaman. Mengkomposisikan cursor dari (created_at, id) dan memfilter dengan WHERE (created_at, id) < ($cursorDate, $cursorId) menjamin paginasi yang stabil dan tidak ambigu. PostgreSQL mendukung perbandingan nilai-baris pada indeks komposit, sehingga tetap efisien.
Cursor pagination tidak mendukung lompatan ke halaman sembarang — Anda harus beraginasi secara berurutan. Untuk sebagian besar antarmuka seperti infinite scroll atau tombol next/previous hal ini tidak masalah, tetapi UI admin yang memungkinkan pengguna mengetik nomor halaman membutuhkan pendekatan berbeda. Post ini merekomendasikan pendekatan hybrid: gunakan cursor pagination untuk navigasi next/previous berurutan dan offset hanya untuk input nomor halaman.
Nilai cursor harus bersifat buram bagi klien, di-encode sebagai base64url JSON yang berisi nilai sort key mentah (misalnya created_at dan id dari item terakhir). Penggunaan base64url menghindari karakter padding yang dapat menyebabkan masalah di query string. Di server, selalu decode dan validasi nilai cursor sebelum digunakan dalam kueri SQL untuk mencegah SQL injection, serta tangani kasus di mana cursor menunjuk ke rekaman yang sudah dihapus dengan mengembalikan halaman kosong secara graceful.
OFFSET 10000 memaksa PostgreSQL untuk memindai dan membuang 10.000 baris sebelum mengembalikan hasil. Pada halaman 1 (OFFSET 0), kueri Anda membutuhkan 10ms. Pada halaman 1.000 (OFFSET 10.000), kueri yang sama pada tabel 1 juta baris dapat membutuhkan 5.000ms. Benchmark menunjukkan cursor pagination 17x lebih cepat dari offset untuk halaman yang dalam pada dataset besar.
| Dimensi | Offset Pagination | Cursor Pagination |
|---|---|---|
| Performa query di halaman dalam | Menurun linear, halaman ke-1.000 pada 1 juta baris bisa memakan waktu detik | Waktu konstan berapa pun dalamnya halaman |
| Kompleksitas implementasi | Sepele: LIMIT dan OFFSET | Butuh sort key yang stabil dan encoding cursor opaque |
| Akses halaman acak | Bisa langsung loncat ke nomor halaman mana pun | Hanya mendukung next atau previous, tidak bisa loncat sembarang halaman |
| Konsistensi saat ada penulisan | Baris bisa bergeser antar halaman kalau data berubah saat scroll | Hasil tetap stabil walau ada baris yang ditambah atau dihapus |
| Tampilan total count | Mudah: COUNT bersamaan dengan query | Butuh query count terpisah, sering dilewati demi performa |
| Paling cocok untuk | Tabel admin dengan nomor halaman dan dataset kecil | Infinite scroll dan dataset besar seperti riwayat transaksi |
Klausul OFFSET SQL memberi tahu database untuk melewati N baris sebelum mengembalikan hasil. Untuk melakukan ini, PostgreSQL harus mengidentifikasi N baris pertama, membuangnya, lalu mengembalikan M baris berikutnya. Seiring bertumbuhnya OFFSET, begitu pula pekerjaannya. Ini adalah O(OFFSET) — biaya tumbuh secara linear dengan seberapa dalam Anda ke dalam dataset.
Offset pagination memiliki masalah kedua: konsistensi data. Jika Anda berada di halaman 5 dari daftar faktur dan faktur baru dimasukkan di bagian atas urutan sort, ketika Anda meminta halaman 6, Anda akan melihat duplikat dari faktur terakhir dari halaman 5 atau melewatkan faktur. Dalam sistem penulisan tinggi seperti ERP, ini berarti pengguna yang beraginasi melalui riwayat transaksi bisa mendapatkan tampilan yang tidak konsisten.
Cursor pagination menggantikan OFFSET dengan klausa WHERE yang memfilter berdasarkan nilai terakhir yang dilihat. Alih-alih 'beri saya baris 10.001 hingga 10.010', Anda mengatakan 'beri saya 10 baris di mana created_at < $lastCursor'. Ini adalah kueri rentang pada kolom yang diindeks — O(log N) terlepas dari seberapa banyak halaman yang Anda masuki. Cursor adalah token buram (biasanya nilai base64-encoded dari sort key) yang dikirim klien dengan setiap permintaan.
Offset Pagination Performance (1M row table):
─────────────────────────────────────────────────
Page 1 (OFFSET 0): ~10ms ✓
Page 10 (OFFSET 90): ~12ms ✓
Page 100 (OFFSET 900): ~40ms ⚠
Page 1000 (OFFSET 9,000): ~400ms ✗
Page 5000 (OFFSET 49,000): ~2000ms ✗ TIMEOUT RISK
Cursor Pagination Performance (1M row table):
─────────────────────────────────────────────────
Any page depth: ~5ms ✓ O(log N)
Page 1: WHERE TRUE LIMIT 10 → index scan start
Page 1000: WHERE (created_at, id) < ($1, $2) LIMIT 10
→ index seek to cursor position, then 10 rows
SQL comparison:
Offset: SELECT * FROM invoices ORDER BY created_at DESC LIMIT 10 OFFSET 9900
↑ must traverse 9,900 rows to find start
Cursor: SELECT * FROM invoices
WHERE (created_at, id) < ('2024-03-15 10:23:44', 1234)
ORDER BY created_at DESC, id DESC LIMIT 10
↑ seeks directly to cursor position in indexDari pengalaman saya mengimplementasikan cursor pagination di API ERP: gunakan cursor komposit saat mengurutkan berdasarkan kolom non-unik seperti `created_at`. Jika dua faktur dibuat pada milidetik yang persis sama, cursor satu bidang akan ambigu dan halaman bisa tumpang tindih atau melewatkan rekaman. Komposisikan cursor dari `(created_at, id)` — urutkan berdasarkan `created_at DESC, id DESC`, encode kedua nilai ke dalam cursor, dan filter dengan `WHERE (created_at, id) < ($cursorDate, $cursorId)`. PostgreSQL mendukung perbandingan nilai-baris pada indeks, membuat ini efisien.
Implementasi NestJS melibatkan utilitas encoder/decoder cursor, query builder yang dimodifikasi, dan bentuk respons yang terstandarisasi. Respons mengembalikan items (halaman saat ini), nextCursor (null jika tidak ada lagi halaman), dan hasMore (boolean). Klien menyimpan cursor dan mengirimkannya pada permintaan berikutnya.
Nilai cursor harus buram bagi klien — ini adalah detail implementasi. Encode nilai mentah sebagai base64 JSON: `Buffer.from(JSON.stringify({created_at: lastItem.createdAt, id: lastItem.id})).toString('base64url')`. Decode dan validasi di server sebelum digunakan dalam kueri SQL — selalu validasi nilai cursor untuk mencegah SQL injection.
// cursor-pagination.util.ts
export function encodeCursor(createdAt: Date, id: number): string {
return Buffer.from(
JSON.stringify({ created_at: createdAt.toISOString(), id })
).toString('base64url')
}
export function decodeCursor(cursor: string): { created_at: string; id: number } {
try {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'))
} catch {
throw new Error('Invalid cursor')
}
}
// invoices.service.ts — NestJS cursor pagination
@Injectable()
export class InvoicesService {
constructor(private readonly dataSource: DataSource) {}
async findAll(limit: number, cursor?: string) {
const take = Math.min(limit, 100) // cap page size
let cursorWhere = ''
const params: unknown[] = [take + 1] // fetch +1 to detect hasMore
if (cursor) {
const { created_at, id } = decodeCursor(cursor)
cursorWhere = 'AND (created_at, id) < ($2, $3)'
params.push(created_at, id)
}
const rows = await this.dataSource.query(
`SELECT id, invoice_number, amount, created_at
FROM invoices
WHERE 1=1 ${cursorWhere}
ORDER BY created_at DESC, id DESC
LIMIT $1`,
params
)
const hasMore = rows.length > take
const items = hasMore ? rows.slice(0, take) : rows
const lastItem = items[items.length - 1]
return {
items,
hasMore,
nextCursor: hasMore && lastItem
? encodeCursor(new Date(lastItem.created_at), lastItem.id)
: null,
}
}
}Cursor pagination standar adalah satu arah — Anda hanya bisa maju. Untuk tombol 'halaman sebelumnya', implementasikan cursor dua arah: pertahankan nextCursor dan prevCursor dalam setiap respons. prevCursor berasal dari item pertama di halaman saat ini daripada yang terakhir. Ini menggandakan kompleksitas implementasi Anda tetapi memungkinkan navigasi prev/next penuh sambil mempertahankan performa O(log N) di kedua arah.
Trade-off utama cursor pagination adalah Anda tidak dapat langsung melompat ke halaman 50 — Anda harus beraginasi secara berurutan. Untuk sebagian besar antarmuka pengguna (infinite scroll, tombol 'load more', navigasi next/previous), ini sempurna. Tetapi untuk UI admin dengan input nomor halaman, pengguna mengharapkan untuk mengetik '50' dan melompat ke halaman 50. Jika produk Anda memerlukan lompatan halaman sembarang, Anda memerlukan offset pagination (menerima biaya performa), pendekatan hybrid, atau offset yang didenormalisasi untuk kasus penggunaan tertentu.
Relay Connection Specification GraphQL mendefinisikan pola cursor pagination yang terstandarisasi: tipe `Connection` dengan `edges` (array dari `{node, cursor}`), `pageInfo` ({hasPreviousPage, hasNextPage, startCursor, endCursor}), dan `totalCount`. Mengimplementasikan ini di NestJS dengan @nestjs/graphql memberi API Anda antarmuka standar yang dapat bekerja dengan klien GraphQL mana pun.
Cursor pagination hanya cepat jika kolom sort Anda memiliki indeks. Untuk `ORDER BY created_at DESC, id DESC`, buat indeks komposit: `CREATE INDEX idx_invoices_created_id ON invoices (created_at DESC, id DESC)`. PostgreSQL dapat menggunakan indeks ini untuk ORDER BY maupun filter cursor WHERE, menghilangkan sequential scan sepenuhnya.