Streams & Buffers — Node.js

Streams memungkinkan kamu memproses data secara bertahap (chunk by chunk) tanpa harus memuat semuanya ke memori. 4 tipe Stream di Node.js: Readable — sumber…

Buffer adalah data mentah dalam bentuk byte, dan stream adalah cara memproses data sedikit demi sedikit (per chunk) tanpa memuat semuanya ke memori. Bayangin memindahkan air dari tandon ke ember. Kamu gak mengangkat tandonnya: kamu pasang selang, dan air mengalir sedikit-sedikit. Kalau ember di ujung lambat (misalnya sambil disaring), kerannya dikecilkan supaya air gak meluap. Selang itu stream, tiap semburan air itu chunk, isinya byte (Buffer), dan mengecilkan keran itu namanya backpressure.

🧪 Tebakan cepat: Buffer.from("café").length hasilnya berapa? Kata itu 4 huruf. Tebak dulu, jawabannya ada di bagian "Buffer: byte mentah".

Buffer: byte mentah

File, request jaringan, dan gambar semuanya datang sebagai byte: angka 0 sampai 255. Node menyimpannya di Buffer dan menampilkannya dalam hex:

const buf = Buffer.from("Halo Dunia", "utf-8");
console.log(buf);            // <Buffer 48 61 6c 6f 20 44 75 6e 69 61>
console.log(buf.toString()); // Halo Dunia
console.log(buf.length);     // 10

console.log(Buffer.from("Node").length); // 4
console.log(Buffer.from("café"));        // <Buffer 63 61 66 c3 a9>
console.log(Buffer.from("café").length); // 5

Stream: membaca sepotong-sepotong

catatan-api punya route GET /catatan/ekspor yang mengirim catatan.csv, 5.000.000 byte. Dengan fs.readFile, seluruh 5 MB masuk memori dulu, baru dikirim. Seratus orang mengunduh bersamaan berarti 500 MB. Dengan stream, file dibaca per chunk:

const fs = require("node:fs");

const stream = fs.createReadStream("catatan.csv");
let jumlah = 0;
stream.on("data", (chunk) => {
  jumlah++;
  console.log("Chunk " + jumlah + ": " + chunk.length + " byte");
});
stream.on("end", () => console.log("Selesai, " + jumlah + " chunk"));
stream.on("error", (err) => console.error("Gagal: " + err.message));
Chunk 1: 65536 byte
Chunk 2: 65536 byte
...
Chunk 77: 19264 byte
Selesai, 77 chunk

Tiap chunk adalah Buffer 65.536 byte (64 KiB), ukuran baku createReadStream: 76 chunk penuh dan sisa 19.264 byte. Yang ada di memori pada satu waktu cuma satu chunk itu. Node punya empat jenis stream: Readable (sumber, misalnya file yang dibaca), Writable (tujuan, misalnya res), Duplex (dua-duanya, misalnya socket), dan Transform (mengubah data yang lewat, misalnya kompresi gzip).

pipe() dan pipeline(): menyambung selang

pipe() menyambungkan Readable ke Writable, jadi tiap chunk yang dibaca langsung ditulis:

app.get("/catatan/ekspor", (req, res) => {
  res.setHeader("Content-Type", "text/csv");
  fs.createReadStream("catatan.csv").pipe(res);
});

pipeline() melakukan hal yang sama untuk beberapa stream sekaligus, dan memanggil satu callback saat selesai atau gagal:

const { pipeline } = require("node:stream");
const zlib = require("node:zlib");

pipeline(fs.createReadStream("catatan.csv"), zlib.createGzip(), fs.createWriteStream("catatan.csv.gz"), (err) => {
  console.log(err ? "Gagal: " + err.message : "Selesai: catatan.csv.gz");
});

Kalau catatan.csv gak ada, yang tercetak Gagal: ENOENT: no such file or directory, open 'catatan.csv', dan semua stream di dalamnya ditutup. Dengan .pipe() berantai, error seperti itu harus kamu tangkap satu per satu.

Backpressure: kalau konsumen lebih lambat

Membaca dari disk itu cepat, mengirim ke HP pengguna lewat sinyal lemah itu lambat. Kalau pembaca terus mengirim chunk, antrean di tengah membengkak di memori. Makanya writable.write(chunk) mengembalikan false begitu antreannya melewati highWaterMark (64 KiB sejak Node 22, sebelumnya 16 KiB): tanda untuk berhenti dulu sampai event "drain".

readable.on("data", (chunk) => {
  if (!writable.write(chunk)) readable.pause(); // antrean penuh: berhenti membaca
});
writable.on("drain", () => readable.resume()); // antrean kosong lagi: lanjut

pipe() dan pipeline() sudah melakukan ini untukmu. Web Streams di browser memakai ide yang sama: highWaterMark menentukan berapa chunk boleh mengantre, dan controller.desiredSize memberi tahu sisa tempatnya. Di Lab, coba konsumen lambat dan lihat antreannya berhenti di highWaterMark.

Huruf yang terpotong di batas chunk

Batas chunk ditentukan jumlah byte, bukan huruf, jadi satu huruf UTF-8 bisa terbelah. "ngopi di café" dibaca per 13 byte:

fs.createReadStream("kopi.txt", { highWaterMark: 13 }).on("data", (chunk) => {
  console.log(chunk, chunk.toString());
});
<Buffer 6e 67 6f 70 69 20 64 69 20 63 61 66 c3> ngopi di caf�
<Buffer a9> �

c3 dan a9 terpisah, dan masing-masing diubah jadi teks sendiri-sendiri, jadi muncul � dua kali. Perbaikannya: minta stream-nya yang mengurus teks dengan { encoding: "utf8" }. Byte yang belum lengkap ditahan sampai pasangannya datang, hasilnya "ngopi di caf" lalu "é". Di browser, new TextDecoder().decode(chunk, { stream: true }) melakukan hal yang sama.

Di latihan: Buffer.from dan potongan

Latihannya mengukur "Halo Dunia" dengan Buffer.from(...).length, lalu memotong teks 54 karakter "Ini adalah contoh data yang akan diproses secara chunk" per 10 karakter, seperti stream memotong file. Potongan terakhir boleh lebih pendek:

Buffer: "Halo Dunia" = 10 bytes
Data: "Ini adalah contoh data yang akan diproses secara chunk"
Chunk size: 10
Jumlah chunks: 6
Chunk 1: "Ini adalah"
Chunk 2: " contoh da"
Chunk 3: "ta yang ak"
Chunk 4: "an diprose"
Chunk 5: "s secara c"
Chunk 6: "hunk"
Buffer: "café" = 5 bytes

Baris terakhir mengukur "café" dengan cara yang sama: 5 byte, bukan 4, karena é makan 2 byte.

⚠️ Jebakan yang sering ketemu

TL;DR: Buffer menyimpan byte mentah: Buffer.from("Halo Dunia") mencetak <Buffer 48 61 6c 6f 20 44 75 6e 69 61>, length-nya 10 byte, dan "café" jadi 5 byte karena é = c3 a9. Stream memproses data per chunk (64 KiB untuk createReadStream) supaya memori gak penuh. Ada Readable, Writable, Duplex, dan Transform. pipe() menyambungkan Readable ke Writable, pipeline() juga menangani error dan menutup semuanya. Backpressure: write() mengembalikan false saat antrean melewati highWaterMark, lalu tunggu "drain" (pipe melakukannya sendiri). Huruf multi-byte bisa terbelah di batas chunk, jadi baca teks dengan { encoding: "utf8" } atau TextDecoder { stream: true }.

Yang akan kamu pelajari