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.
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.