Stel je voor dat je een bestand van 10 GB moet verwerken op een server met 2 GB RAM. Alles in één keer inlezen? Dan crasht je Node.js-proces vrijwel direct. Dit is precies waar streaming en backpressure in Node.js je redden: je verwerkt data in kleine chunks, zonder dat je geheugen volloopt.
Streams zijn een van de krachtigste, en tegelijk meest onderschatte, onderdelen van Node.js. Ze zitten onder de motorkap van bijna elke I/O-operatie die je doet: HTTP-requests, bestanden lezen, database-resultaten, en zelfs console.log. In dit artikel duiken we in hoe streams werken, waarom backpressure cruciaal is en hoe je het in de praktijk toepast.
Wat is een stream eigenlijk?
Een stream is een abstractie voor data die in de tijd doorstroomt. In plaats van te wachten tot alle data beschikbaar is, verwerk je het stukje bij beetje, zogeheten chunks. Denk aan water uit een kraan: je vult je glas niet in één keer, maar in een constante stroom.
Node.js kent vier typen streams:
- Readable, data komt eruit (bijv.
fs.createReadStream). - Writable, data gaat erin (bijv.
fs.createWriteStream). - Duplex, zowel lezen als schrijven (bijv. een TCP-socket).
- Transform, een duplex stream die data tijdens het doorgeven aanpast (bijv. compressie).
Omdat streams bovenop de event loop werken, blokkeren ze de hoofdthread niet. Je server blijft responsief terwijl er gigabytes aan data doorheen gaan.
Waarom streams zo belangrijk zijn
Zonder streams zou elke Node.js-applicatie enorme hoeveelheden geheugen vreten bij grote dataverwerking. Een upload van 500 MB? Dat zou betekenen dat je server 500 MB buffer moet aanleggen per gelijktijdige upload. Met tien gebruikers zit je al op 5 GB.
Streams lossen dit op met een paar grote voordelen:
- Lagere memory footprint, je houdt maar een paar kilobytes tegelijk in het geheugen.
- Snellere time-to-first-byte, de eerste chunk kan al verwerkt worden terwijl de rest nog onderweg is.
- Betere schaalbaarheid, essentieel bij clustering en scaling in Node.js.
- Composable, je koppelt streams aan elkaar als LEGO-blokken.
Een concreet voorbeeld: een bestand kopiëren zonder streams.
import fs from 'node:fs/promises';
// Slechte aanpak: alles in memory
const data = await fs.readFile('big-file.zip');
await fs.writeFile('copy.zip', data);
En met streams:
import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
await pipeline(
fs.createReadStream('big-file.zip'),
fs.createWriteStream('copy.zip')
);
De tweede versie werkt ook bij bestanden van tientallen gigabytes op een server met weinig geheugen.
Wat is backpressure?
Hier wordt het interessant. Stel je leest data uit een snelle SSD en schrijft naar een trage netwerkverbinding. De bron produceert veel sneller dan de bestemming kan verwerken. Wat gebeurt er met al die data die nog geen kant op kan?
Zonder maatregelen stapelt data zich op in een interne buffer. Loopt die buffer vol, dan groeit je memory-gebruik totdat Node.js omvalt. Backpressure is het signaal waarmee de writable stream zegt: "Ho, stop even met produceren, ik kan het niet bijhouden."
In de praktijk werkt dat zo: wanneer je .write() aanroept op een writable stream en de interne buffer is vol, geeft hij false terug. Jij moet dan stoppen met schrijven en wachten op het 'drain'-event.
function writeData(writable, data) {
if (!writable.write(data)) {
// Buffer vol, wacht op drain
return new Promise(resolve => writable.once('drain', resolve));
}
return Promise.resolve();
}
Als je backpressure negeert, heb je een tikkende tijdbom. Alles werkt prima in tests met kleine data, maar klapt in productie zodra een gebruiker een groot bestand uploadt.
Readable streams in de praktijk
Een readable stream heeft twee leesmodi: flowing en paused. In flowing-modus pusht de stream data via 'data'-events. In paused-modus haal je zelf data op met .read().
import fs from 'node:fs';
const stream = fs.createReadStream('groot-bestand.log', {
encoding: 'utf8',
highWaterMark: 64 * 1024 // 64 KB per chunk
});
stream.on('data', chunk => {
console.log(`Ontvangen: ${chunk.length} bytes`);
});
stream.on('end', () => console.log('Klaar'));
stream.on('error', err => console.error('Fout:', err));
De highWaterMark bepaalt de buffergrootte. Groter = meer throughput, maar ook meer memory. De default is 64 KB voor bestanden en 16 KB voor object mode streams.
Async iteratie: moderne streams
Sinds Node.js 12 kun je readable streams gewoon met for await...of doorlopen. Dit combineert heerlijk met async/await patronen:
import fs from 'node:fs';
async function telRegels(pad) {
let regels = 0;
const stream = fs.createReadStream(pad, { encoding: 'utf8' });
let buffer = '';
for await (const chunk of stream) {
buffer += chunk;
const parts = buffer.split('\n');
buffer = parts.pop();
regels += parts.length;
}
return regels;
}
Backpressure wordt hier automatisch afgehandeld, iteratie pauzeert zolang jouw code bezig is.
Writable streams en de drain-event
Bij writable streams draait het allemaal om de returnwaarde van .write(). Zodra die false wordt, ben jij aan zet om te pauzeren.
import fs from 'node:fs';
const writable = fs.createWriteStream('output.txt');
function schrijfMiljoenRegels() {
let i = 0;
function write() {
let ok = true;
while (i < 1_000_000 && ok) {
ok = writable.write(`regel ${i++}\n`);
}
if (i < 1_000_000) {
writable.once('drain', write);
} else {
writable.end();
}
}
write();
}
Dit patroon, schrijven tot false, wachten op 'drain', herhalen, is de kern van handmatige backpressure. Gelukkig hoef je dit zelden zelf te schrijven als je pipeline gebruikt.
Pipeline: de veilige manier om streams te koppelen
De oude .pipe()-methode koppelt streams, maar laat errors stil mislukken en ruimt bij faalscenario's niet altijd goed op. Gebruik daarom altijd pipeline:
import fs from 'node:fs';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
try {
await pipeline(
fs.createReadStream('log.txt'),
zlib.createGzip(),
fs.createWriteStream('log.txt.gz')
);
console.log('Klaar met comprimeren');
} catch (err) {
console.error('Pipeline gefaald:', err);
}
Pipeline zorgt voor drie dingen die je anders zelf moet regelen: backpressure tussen alle schakels, errorpropagatie, en resource cleanup bij falen. Voor robuuste error handling op schaal is dit onmisbaar.
Transform streams: data onderweg aanpassen
Een transform stream is de Zwitserse zakmes van streaming. Je krijgt input binnen, past het aan, en geeft output door. Ideaal voor parsing, encoding, filtering of verrijken.
import { Transform } from 'node:stream';
class UpperCase extends Transform {
_transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
}
}
import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
await pipeline(
fs.createReadStream('input.txt'),
new UpperCase(),
fs.createWriteStream('output.txt')
);
Transform streams zijn perfect voor ETL-achtige processen. Combineer ze met background jobs en workers om zware batchverwerking buiten je HTTP-verkeer te houden.
Streams en HTTP
Elk HTTP-request in Node.js is een stream. Het req-object is een readable stream, res is een writable stream. Dit betekent dat je uploads kunt streamen zonder de hele body in memory te bufferen:
import http from 'node:http';
import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
http.createServer(async (req, res) => {
if (req.method === 'POST') {
try {
await pipeline(req, fs.createWriteStream('upload.bin'));
res.writeHead(200);
res.end('Upload voltooid');
} catch {
res.writeHead(500);
res.end('Mislukt');
}
}
}).listen(3000);
Hetzelfde principe werkt voor het leveren van grote bestanden aan de client: stream vanaf disk direct naar res. Zie ook onze gids over HTTP servers en routing voor meer context.
Object mode: streamen van JavaScript-objecten
Standaard werken streams met Buffer of strings. Zet je objectMode: true, dan kun je elk JavaScript-object door de pipeline sturen. Dit is goud waard voor data-pipelines:
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const bron = Readable.from([
{ id: 1, naam: 'Anna' },
{ id: 2, naam: 'Bram' }
]);
const verrijk = new Transform({
objectMode: true,
transform(record, enc, cb) {
cb(null, { ...record, verwerkt: new Date() });
}
});
await pipeline(bron, verrijk, async function* (source) {
for await (const record of source) {
console.log(record);
}
});
Deze aanpak is krachtig voor integraties met databases in Node.js waarbij je resultaten rij voor rij wilt verwerken zonder alles in memory te laden.
Veelgemaakte fouten
Een paar valkuilen die we vaak tegenkomen bij code reviews:
.pipe()gebruiken in productie, wissel naarpipelinevoor correcte error handling.- Backpressure negeren in loops,
while(true) writable.write(...)vult je memory in seconden. - Errors niet afhandelen, een uncaught stream error kan je hele proces crashen.
- Te grote highWaterMark, 1 MB buffers klinken snel, maar bij 1000 connecties is dat 1 GB.
- Streams niet sluiten, vergeet
end()niet bij writables, of gebruikpipeline.
Zie ook de officiële Node.js streams documentatie en de Stream backpressuring guide voor diepgaande uitleg.
Wanneer géén streams?
Streams zijn krachtig, maar niet altijd de juiste keuze. Voor kleine payloads (< 100 KB) is de overhead niet waard. Een simpele JSON-response? Gewoon res.json(). Een config-bestand inlezen? fs.readFile is prima.
Streams verdienen hun complexiteit bij:
- Bestanden groter dan een paar MB.
- Continue datastromen (logs, events, sensordata).
- Netwerk-proxies en gateways.
- ETL-pipelines en batchverwerking.
- Streaming responses zoals Server-Sent Events.
Veelgestelde vragen
Wat is streaming in Node.js?
Streaming is een manier om data in kleine stukjes (chunks) te verwerken in plaats van alles in één keer in het geheugen te laden. Hiermee kun je grote bestanden, netwerkdata of realtime feeds efficiënt verwerken zonder je server te overbelasten.
Wat betekent backpressure?
Backpressure is het mechanisme waarmee een stream aangeeft dat hij te veel data binnenkrijgt om bij te houden. De producent (bijvoorbeeld een lees-stream) pauzeert dan tijdelijk totdat de consument (bijvoorbeeld een schrijf-stream) weer klaar is om data te verwerken.
Wat is het verschil tussen pipe en pipeline?
Zowel pipe als pipeline koppelen streams aan elkaar, maar pipeline handelt errors en cleanup automatisch af. Voor productiecode gebruik je altijd pipeline of stream.promises.pipeline, omdat pipe stil kan falen bij errors.
Wanneer gebruik je streams en wanneer niet?
Gebruik streams zodra je met grote datasets, bestanden of continue datastromen werkt. Voor kleine data (enkele kilobytes) is een gewone buffer of string vaak simpeler en sneller dan de overhead van een stream.
Wat zijn object mode streams?
Object mode streams verwerken JavaScript-objecten in plaats van buffers of strings. Handig voor datatransformatie-pipelines waarin je bijvoorbeeld database-rijen of JSON-records wilt streamen en transformeren.