Katalog toko buku tumbuh dari 23 judul menjadi 23 ribu, dan permintaan GET /buku yang dulu ringan kini harus menyalin ribuan record ke jaringan — client menunggu lama, server haus memori, dan browser menampung data yang mungkin tidak pernah dilihat pengguna. Solusi standar industri adalah pagination: server mengirim data sebagian — satu “halaman” — beserta petunjuk untuk meminta halaman berikutnya. Tugas client berubah menjadi loop yang rapi.

Chapter ini membahas dua skema yang mendominasi API modern: page/limit yang sederhana dan berbasis nomor, serta cursor yang menunjuk posisi terakhir. Keduanya dipraktikkan dengan server tiruan tertanam — node muat-halaman.mjs dan node muat-cursor.mjs menghasilkan keluaran yang sama persis dengan yang tercetak di bawah.

Skema page dan limit #

Kontraknya dua query parameter: page (nomor halaman, mulai dari 1) dan limit (jumlah item per halaman). Responsnya membungkus data dalam amplop (envelope) yang menyertakan metadata — inilah yang disebut meta total, informasi cukup bagi client untuk tahu seberapa jauh harus melanjutkan:

text Contoh: kontrak respons page/limit
GET /buku?page=2&limit=5
{
  "data": [ ...5 buku... ],
  "meta": { "page": 2, "limit": 5, "total": 23, "totalPages": 5 }
}

Kunci keberhasilan loop ada di meta.totalPages — dihitung server dengan Math.ceil(total / limit) supaya halaman terakhir yang tidak penuh tetap terhitung (23 item dengan limit 5 menghasilkan 5 halaman, bukan 4,6).

Loop Memuat Semua Halaman #

Berkas berikut menyertakan server tiruan berisi katalog 23 buku, lalu client memuat seluruhnya halaman demi halaman hingga meta.totalPages tercapai:

// muat-halaman.mjs
// Pagination page/limit: loop memuat semua halaman sampai meta.total habis.
// Jalankan di terminal: node muat-halaman.mjs (tanpa internet).

import http from "node:http";

// Katalog tiruan: 23 buku.
const katalog = Array.from({ length: 23 }, (_, i) => ({ id: i + 1, judul: `Buku No. ${i + 1}` }));

const server = http.createServer((req, res) => {
  const query = new URL(req.url, "http://x").searchParams;
  const page = Number(query.get("page") ?? 1);
  const limit = Number(query.get("limit") ?? 5);

  const mulai = (page - 1) * limit;
  const data = katalog.slice(mulai, mulai + limit);
  const meta = { page, limit, total: katalog.length, totalPages: Math.ceil(katalog.length / limit) };

  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ data, meta }));
});

server.listen(0, "127.0.0.1", async () => {
  const port = server.address().port;

  // Loop: mulai dari halaman 1, berhenti saat meta.totalPages tercapai.
  const semua = [];
  let page = 1;
  let totalPages = Infinity;

  while (page <= totalPages) {
    const response = await fetch(`http://127.0.0.1:${port}/buku?page=${page}&limit=5`);
    const hasil = await response.json();
    totalPages = hasil.meta.totalPages;
    console.log(
      `halaman ${hasil.meta.page}/${totalPages}: ${hasil.data.length} item (total ${hasil.meta.total} buku)`
    );
    semua.push(...hasil.data);
    page++;
  }

  console.log("terkumpul:", semua.length, "buku");
  console.log("pertama  :", semua[0].judul, "| terakhir:", semua[semua.length - 1].judul);

  server.closeAllConnections();
  server.close();
});

Output yang Dihasilkan:

text
halaman 1/5: 5 item (total 23 buku)
halaman 2/5: 5 item (total 23 buku)
halaman 3/5: 5 item (total 23 buku)
halaman 4/5: 5 item (total 23 buku)
halaman 5/5: 3 item (total 23 buku)
terkumpul: 23 buku
pertama  : Buku No. 1 | terakhir: Buku No. 23

Mari kita telaah baris-baris pentingnya:

  1. Baris 11–13 (server membaca query): URL dan searchParams — perangkat dari section Data dan Serialization — mengurai ?page=...&limit=.... Nilai ?? 1 dan ?? 5 memberi default bila parameter tak disertakan.
  2. Baris 15–17 (potong dan amplop): slice(mulai, mulai + limit) mengambil jendela data, sedangkan meta membawa page, limit, total, dan totalPages — amplop inilah kontrak yang dipegang client.
  3. Baris 28–29 (kondisi awal loop): totalPages dimulai dari Infinity supaya perulangan pertama pasti berjalan; nilainya benar baru diperoleh setelah respons pertama dibaca. page dimulai dari 1 karena skema ini 1-based.
  4. Baris 32 (?page=${page}&limit=5): tiap putaran mengirim nomor halaman berjalan. fetch dipanggil dengan await di dalam while — halaman ke-n baru diminta setelah halaman sebelumnya selesai dibaca.
  5. Baris 34 (totalPages = hasil.meta.totalPages): batas loop diperbarui dari meta respons, bukan ditebak client. Jika server suatu saat menambah data, loop otomatis menyesuaikan jumlah putarannya.
  6. Baris 38 (semua.push(...hasil.data)): hasil tiap halaman di-spread ke satu array penyatu. Karena perulangan bersifat berurutan, urutan item di semua tetap benar tanpa pengurutan ulang.
  7. Baris 39 (page++): penerus nomor halaman — kunci berjalan skema ini. Lupa menaikkannya adalah jebakan klasik: loop tak berujung meminta halaman yang sama.
  8. Keluaran halaman 5/5: halaman terakhir hanya berisi 3 item — bukti Math.ceil di sisi server dan kondisi page <= totalPages di sisi client bekerja mengiringi data yang tidak genap.

Perhatikan juga urutan kerja loop ini cocok untuk antarmuka yang menampilkan progres: karena setiap halaman dibaca segera setelah tiba, UI bisa merender 5 buku pertama sebelum halaman lain selesai dimuat — bukan menahan seluruh 23 item di belakang layar “loading”. Bila kebutuhannya justru memuat semuanya lebih cepat, permintaan halaman bisa dijalankan paralel dengan Promise.all — dengan konsekuensi server harus menangani banyak permintaan serentak sekaligus.

Skema Cursor #

page/limit punya titik lemah: ia menghitung dari nomor. Bila data berubah di tengah penelusuran — item baru masuk, item lama terhapus — halaman bergeser dan beberapa item bisa terlewat atau terbaca dua kali. Skema cursor menghindarinya: respons menyertakan nextCursor, penunjuk posisi (“id item terakhir yang sudah kamu terima”), dan client menyodorkan kembali penunjuk itu pada permintaan berikutnya:

// muat-cursor.mjs
// Cursor pagination: tiap respons menyertakan nextCursor untuk halaman berikutnya.
// Jalankan di terminal: node muat-cursor.mjs (tanpa internet).

import http from "node:http";

const log = Array.from({ length: 8 }, (_, i) => ({ id: i + 1, aktivitas: `Aktivitas No. ${i + 1}` }));
const LIMIT = 3;

const server = http.createServer((req, res) => {
  const query = new URL(req.url, "http://x").searchParams;
  const cursor = Number(query.get("cursor") ?? 0);

  // Ambil item yang id-nya lebih besar dari cursor.
  const data = log.filter((item) => item.id > cursor).slice(0, LIMIT);
  const itemTerakhir = data[data.length - 1];
  const nextCursor = itemTerakhir && itemTerakhir.id < log.length ? itemTerakhir.id : null;

  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ data, nextCursor }));
});

server.listen(0, "127.0.0.1", async () => {
  const port = server.address().port;

  // Loop: terus meminta halaman berikutnya selama nextCursor bukan null.
  const semua = [];
  let cursor = null;

  while (true) {
    const alamat = cursor === null ? `/log?limit=${LIMIT}` : `/log?cursor=${cursor}&limit=${LIMIT}`;
    const hasil = await (await fetch(`http://127.0.0.1:${port}${alamat}`)).json();
    console.log(`cursor masuk: ${cursor ?? "(awal)"} -> ${hasil.data.length} item, nextCursor: ${hasil.nextCursor}`);
    semua.push(...hasil.data);
    if (hasil.nextCursor === null) break;
    cursor = hasil.nextCursor;
  }

  console.log("terkumpul:", semua.length, "aktivitas");
  console.log("pertama  :", semua[0].aktivitas, "| terakhir:", semua[semua.length - 1].aktivitas);

  server.closeAllConnections();
  server.close();
});

Output yang Dihasilkan:

text
cursor masuk: (awal) -> 3 item, nextCursor: 3
cursor masuk: 3 -> 3 item, nextCursor: 6
cursor masuk: 6 -> 2 item, nextCursor: null
terkumpul: 8 aktivitas
pertama  : Aktivitas No. 1 | terakhir: Aktivitas No. 8

Mari kita telaah baris-baris pentingnya:

  1. Baris 15 (filter terhadap cursor): server mengambil hanya item yang id-nya lebih besar dari cursor, dibatasi LIMIT. Posisi tidak dihitung dari nomor halaman melainkan dari penunjuk id — perubahan data di depan tidak menggeser hasil.
  2. Baris 17 (aturan berhenti): nextCursor berupa id item terakhir selama masih ada sisa, atau null bila data habis. null inilah sinyal berhenti yang disepakati.
  3. Baris 31 (alamat dinamis): permintaan pertama tanpa cursor; permintaan berikutnya menyodorkan cursor terakhir. Tidak ada perhitungan page * limit sama sekali.
  4. Baris 35 (if (hasil.nextCursor === null) break;): perulangan while (true) berhenti hanya lewat pintu ini — kontrak “ada sambungan atau tidak” sepenuhnya mengikuti kata server.

Memilih Skema yang Tepat #

  • page/limit unggul ketika pengguna perlu melompat: “langsung ke halaman 4” hanya mungkin dengan penomoran. Cocok untuk katalog, tabel admin, dan hasil pencarian. URL-nya juga mudah dibagikan dan disimpan — ?page=3 adalah tautan yang bermakna.
  • cursor unggul untuk data yang bergerak — feed aktivitas, log, pesan — karena penelusuran tetap konsisten meski data bertambah; API media sosial besar hampir semuanya memakai cursor. Kelemahannya: tidak bisa melompat ke halaman tengah, dan cursor biasanya berupa string tidak transparan yang hanya server yang paham.

Dua catatan kecil dari sisi server yang sering terlupa. Pertama, limit perlu dibatasi atasnya — client yang mengirim limit=1000000 seharusnya dijawab dengan nilai maksimum yang disepakati (misalnya 100), bukan dilayani apa adanya. Kedua, sebagian API menyampaikan penunjuk halaman berikutnya lewat header Link pada respons, bukan di body; mekanismenya berbeda, tetapi gagasannya sama — server yang menyediakan jalan ke halaman berikutnya.

Chapter berikutnya menaikkan ketahanan loop seperti ini ke level jaringan: bagaimana bila salah satu halaman gagal karena server sedang sibuk? Di situ pola retry dan batas waktu berperan.