find() tidak mengembalikan array — ia mengembalikan cursor: penunjuk berjalan pada hasil yang tersimpan di sisi server. Perbedaan ini tidak terasa selama kita langsung memanggil toArray(), tetapi menjadi penting begitu data membesar: laporan 50000 baris tidak sehat dimuat sekaligus ke memori, dan daftar produk di aplikasi nyata ditampilkan per halaman. Chapter ini membandingkan cara-cara mengonsumsi cursor — termasuk berhenti di tengah jalan dan menutupnya dengan benar — lalu membangun pagination keyset yang tidak membaca ulang halaman sebelumnya.

Cursor Bekerja Per Batch #

Ketika query dijalankan, server tidak mengirim seluruh hasil dalam satu paket. Server mengirim batch pertama (ukuran bawaan 101 document untuk batch pertama, lalu hingga 16 MB per batch berikutnya), menahan sisanya, dan mengirim batch baru setiap kali buffer lokal driver habis. Dua konsekuensi praktis mengikuti pola ini. Pertama, toArray() hanyalah pintasan “ambil semua batch sampai habis” — seluruh document berakhir di memori proses. Kedua, cursor yang ditinggalkan setengah jalan menahan sumber daya di server sampai di-kill atau timeout — di sanalah close() berperan.

Ada pula jaminan urutan yang perlu diingat: document tiba sesuai urutan sort kita, tetapi tidak ada jaminan dua query terpisah menghasilkan urutan identik bila data berubah di antara keduanya. Itulah alasan pagination berbasis skip bisa menampilkan document yang sama dua kali pada halaman berbeda saat ada data baru disisipkan di tengah penelusuran — masalah yang hilang bersama keyset pagination di bagian akhir chapter ini.

Tiga Cara Mengonsumsi Cursor #

File berikut menjalankan ketiga pola pada data delapan judul, dengan batchSize(2) agar perilaku batch terlihat dari luar:

js
// streaming-vs-toarray.js — tiga cara mengonsumsi FindCursor
// Jalankan: MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node streaming-vs-toarray.js
const { MongoClient } = require("mongodb")

const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/?directConnection=true"

async function main() {
  const client = new MongoClient(uri)
  await client.connect()
  const katalog = client.db("latihan_js").collection("buku")
  await katalog.drop().catch(() => {})

  await katalog.insertMany([
    { judul: "Laskar Pelangi", terjual: 132 },
    { judul: "Bumi Manusia", terjual: 205 },
    { judul: "Atomic Habits", terjual: 318 },
    { judul: "Filosofi Teras", terjual: 276 },
    { judul: "Cantik Itu Luka", terjual: 98 },
    { judul: "Negeri 5 Menara", terjual: 190 },
    { judul: "Sang Pemimpi", terjual: 87 },
    { judul: "Laut Bercerita", terjual: 61 },
  ])
  const proyeksi = { projection: { _id: 0, judul: 1, terjual: 1 } }

  // ===== Cara 1: toArray() — seluruh hasil dimuat ke memori sekaligus =====
  const array = await katalog.find({}, proyeksi).sort({ terjual: -1 }).toArray()
  console.log("toArray   :", array.length, "document dimuat dalam satu array")

  // ===== Cara 2: for await — diproses satu per satu, berhenti lebih awal =====
  const cursor = katalog.find({}, proyeksi).sort({ terjual: -1 }).batchSize(2)
  let diproses = 0
  for await (const doc of cursor) {
    diproses++
    if (diproses === 3) break // cukup tiga teratas
  }
  console.log("for await : diproses", diproses, "document lalu break — cursor.killed:", cursor.killed)

  // ===== Cara 3: hasNext()/next() manual — cursor wajib ditutup sendiri =====
  const cursor2 = katalog.find({}, proyeksi).sort({ terjual: -1 }).batchSize(2)
  let ambil = 0
  while (await cursor2.hasNext()) {
    const doc = await cursor2.next()
    ambil++
    console.log("  ambil ke-", ambil, ":", doc.judul, "| bufferedCount:", cursor2.bufferedCount())
    if (ambil === 3) break
  }
  console.log("loop manual break — cursor.killed:", cursor2.killed)
  await cursor2.close()
  console.log("setelah close()     — cursor.killed:", cursor2.killed)

  await katalog.drop()
  await client.close()
}

main().catch(err => {
  console.error("GAGAL:", err.name, "-", err.message)
  process.exit(1)
})
bash
MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node streaming-vs-toarray.js

Output yang Dihasilkan:

text
toArray   : 8 document dimuat dalam satu array
for await : diproses 3 document lalu break — cursor.killed: true
  ambil ke- 1 : Atomic Habits | bufferedCount: 1
  ambil ke- 2 : Filosofi Teras | bufferedCount: 0
  ambil ke- 3 : Bumi Manusia | bufferedCount: 1
loop manual break — cursor.killed: false
setelah close()     — cursor.killed: true

Mari kita bedah satu per satu. toArray() sederhana dan tepat untuk hasil kecil — delapan document memang tidak masalah. for await...of memproses document begitu tiba, dan break di tengah loop otomatis menutup cursor: protokol async iterator Node.js memanggil cursor.close() saat loop ditinggalkan — terbukti dari cursor.killed: true tanpa satu pun panggilan close() eksplisit.

Loop manual hasNext()/next() cerita lainnya: break di sana tidak menutup apa pun — cursor.killed masih false — sehingga await cursor2.close() wajib dipanggil sendiri; baru setelah itu server melepas cursor-nya. Pola bufferedCount() di sela loop memperlihatkan kerja batchSize(2): document pertama menyisakan 1 di buffer, document kedua menghabiskannya, document ketiga memicu pengambilan batch baru. Naikkan batchSize untuk round-trip lebih jarang; turunkan untuk memori yang lebih ramping dan document pertama tiba lebih cepat.

Keyset Pagination: Lanjut dari Kursor Terakhir #

Pagination dengan skip membaca lalu membuang seluruh document halaman sebelumnya — halaman ke-50 berarti server menyusuri 49 halaman lebih dulu. Keyset pagination membalik pendekatannya: setiap halaman diambil dengan filter “nilai di bawah item terakhir halaman sebelumnya”. Kuncinya: sort yang sama, limit yang sama, dan filter $lt pada field kunci:

js
// keyset-pagination.js — pagination tanpa skip: lanjut dari nilai kursor terakhir
// Jalankan: MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node keyset-pagination.js
const { MongoClient } = require("mongodb")

const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/?directConnection=true"

async function main() {
  const client = new MongoClient(uri)
  await client.connect()
  const katalog = client.db("latihan_js").collection("buku")
  await katalog.drop().catch(() => {})

  await katalog.insertMany([
    { judul: "Laskar Pelangi", terjual: 132 },
    { judul: "Bumi Manusia", terjual: 205 },
    { judul: "Atomic Habits", terjual: 318 },
    { judul: "Filosofi Teras", terjual: 276 },
    { judul: "Cantik Itu Luka", terjual: 98 },
    { judul: "Negeri 5 Menara", terjual: 190 },
    { judul: "Sang Pemimpi", terjual: 87 },
    { judul: "Laut Bercerita", terjual: 61 },
  ])
  const proyeksi = { projection: { _id: 0, judul: 1, terjual: 1 } }
  const UKURAN_HALAMAN = 3

  // Halaman pertama: sort + limit, seperti biasa
  let halaman = await katalog.find({}, proyeksi).sort({ terjual: -1 }).limit(UKURAN_HALAMAN).toArray()
  let nomor = 1
  while (halaman.length > 0) {
    console.log(`halaman ${nomor}:`, halaman)

    // Kursor = nilai field terakhir halaman yang baru selesai dibaca
    const kursorNilai = halaman[halaman.length - 1].terjual

    // Halaman berikutnya: hanya document dengan nilai DI BAWAH kursor
    halaman = await katalog.find({ terjual: { $lt: kursorNilai } }, proyeksi)
      .sort({ terjual: -1 })
      .limit(UKURAN_HALAMAN)
      .toArray()
    nomor++
  }
  console.log("tidak ada lagi halaman berikutnya")

  await katalog.drop()
  await client.close()
}

main().catch(err => {
  console.error("GAGAL:", err.name, "-", err.message)
  process.exit(1)
})
bash
MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node keyset-pagination.js

Output yang Dihasilkan:

text
halaman 1: [
  { judul: 'Atomic Habits', terjual: 318 },
  { judul: 'Filosofi Teras', terjual: 276 },
  { judul: 'Bumi Manusia', terjual: 205 }
]
halaman 2: [
  { judul: 'Negeri 5 Menara', terjual: 190 },
  { judul: 'Laskar Pelangi', terjual: 132 },
  { judul: 'Cantik Itu Luka', terjual: 98 }
]
halaman 3: [
  { judul: 'Sang Pemimpi', terjual: 87 },
  { judul: 'Laut Bercerita', terjual: 61 }
]
tidak ada lagi halaman berikutnya

Baris const kursorNilai = halaman[halaman.length - 1].terjual mengambil nilai kunci dari item terakhir yang benar-benar sudah dibaca — inilah “kursor”-nya. Query berikutnya memakai { terjual: { $lt: kursorNilai } } sehingga server langsung melompat ke document berikutnya tanpa menyentuh halaman sebelumnya; dengan index pada field kunci, tiap halaman berbiaya tetap tidak peduli sedalam apa pengguna menelusuri. Loop berhenti alami ketika halaman kosong.

Rangkuman #

  • find() mengembalikan cursor; toArray() memuat seluruh hasil ke memori, for await...of memproses document satu per satu.
  • break pada for await otomatis menutup cursor; loop manual hasNext()/next() menuntut await cursor.close() eksplisit.
  • batchSize() mengatur ukuran batch yang dikirim server — teramati lewat cursor.bufferedCount().
  • Keyset pagination ($lt pada nilai terakhir + sort + limit tetap) mengambil tiap halaman dengan biaya tetap; kunci sort wajib unik, gunakan _id sebagai pemecah seri.