Streams in Node.js: Große Datenmengen effizient verarbeiten

Warum du in Node.js nicht immer ganze Dateien in den Speicher laden musst – und wie du mit Readable-, Writable- und Transform-Streams Daten Stück für Stück verarbeitest.

Teilen

Stell dir vor, du willst eine 2 GB große Logdatei einlesen und Zeile für Zeile durchsuchen. Der naive Weg wäre, die komplette Datei mit fs.readFile in eine Variable zu laden – und schon belegt dein Prozess 2 GB Arbeitsspeicher, bevor überhaupt die erste Zeile verarbeitet wird. Genau für dieses Problem gibt es in Node.js Streams. Sie verarbeiten Daten häppchenweise, während sie eintreffen, statt alles auf einmal in den Speicher zu holen. In diesem Beitrag lernst du, wie Streams funktionieren und wie du sie im Alltag einsetzt.

Was ist ein Stream?

Ein Stream ist ein Datenstrom, der in kleinen Portionen – sogenannten Chunks – fließt. Statt auf die vollständigen Daten zu warten, kannst du jeden Chunk sofort verarbeiten und danach wieder verwerfen. Das hält den Speicherverbrauch niedrig und konstant, egal ob die Quelle 2 KB oder 2 GB groß ist.

Node.js kennt vier Grundtypen von Streams:

  • Readable – eine Datenquelle, aus der du liest (z. B. eine Datei oder eine HTTP-Antwort).
  • Writable – ein Ziel, in das du schreibst (z. B. eine Datei oder die Konsole).
  • Duplex – lesbar und schreibbar zugleich (z. B. ein Netzwerk-Socket).
  • Transform – ein Duplex-Stream, der Daten beim Durchfließen verändert (z. B. komprimiert oder umwandelt).

Eine Datei als Stream lesen

Mit fs.createReadStream erhältst du einen Readable-Stream. Du reagierst auf Ereignisse: data für jeden Chunk, end wenn alles gelesen ist und error im Fehlerfall. Der Parameter highWaterMark bestimmt, wie groß ein Chunk maximal ist.

const fs = require('fs');

const stream = fs.createReadStream('daten.txt', {
  encoding: 'utf8',
  highWaterMark: 16 * 1024, // 16 KB pro Chunk
});

stream.on('data', (chunk) => {
  console.log(`Chunk erhalten: ${chunk.length} Zeichen`);
});

stream.on('end', () => {
  console.log('Datei komplett gelesen.');
});

stream.on('error', (err) => {
  console.error('Fehler:', err.message);
});

Egal wie groß daten.txt ist – dein Speicherbedarf bleibt bei etwa der Chunk-Größe. Das ist der ganze Trick.

Daten mit einem Writable-Stream schreiben

Das Gegenstück ist fs.createWriteStream. Du rufst wiederholt write() auf und schließt den Stream am Ende mit end(). Node kümmert sich darum, die Daten portionsweise auf die Festplatte zu schreiben.

const fs = require('fs');

const stream = fs.createWriteStream('ausgabe.txt');

for (let i = 1; i <= 5; i++) {
  stream.write(`Zeile ${i}\n`);
}

stream.end(() => {
  console.log('Alle Zeilen geschrieben.');
});

Ein wichtiges Detail ist Backpressure: Wenn write() false zurückgibt, ist der interne Puffer voll und du solltest mit dem Schreiben pausieren, bis das drain-Ereignis feuert. In der Praxis nimmt dir pipe() genau diese Buchhaltung ab.

pipe(): Streams miteinander verbinden

Die eleganteste Art, einen Readable- mit einem Writable-Stream zu koppeln, ist pipe(). Damit kopierst du eine Datei, ohne sie jemals vollständig in den Speicher zu laden – und Backpressure wird automatisch berücksichtigt.

const fs = require('fs');

const quelle = fs.createReadStream('gross.log');
const ziel = fs.createWriteStream('kopie.log');

quelle.pipe(ziel);

ziel.on('finish', () => {
  console.log('Kopie fertig – ganz ohne die Datei komplett zu laden.');
});

Du kannst mehrere pipe()-Aufrufe verketten und so eine ganze Verarbeitungskette bauen: von der Quelle über eine Transformation bis zum Ziel.

Eigene Transform-Streams schreiben

Ein Transform-Stream sitzt in der Mitte einer Kette und verändert die Daten im Vorbeifließen. Du implementierst nur die transform-Methode, die jeden Chunk entgegennimmt und über callback(fehler, ergebnis) weitergibt. Das folgende Beispiel wandelt alle Eingaben in Großbuchstaben um.

const { Transform } = require('stream');

const grossbuchstaben = new Transform({
  transform(chunk, encoding, callback) {
    const text = chunk.toString().toUpperCase();
    callback(null, text);
  },
});

// Tippe etwas ins Terminal und beende mit Strg+D
process.stdin.pipe(grossbuchstaben).pipe(process.stdout);

Weil Transform-Streams sowohl lesbar als auch schreibbar sind, lassen sie sich beliebig in pipe()-Ketten einbauen – ideal für Aufgaben wie Filtern, Parsen oder Komprimieren.

pipeline(): Fehler zuverlässig behandeln

Bei langen pipe()-Ketten gibt es eine Tücke: Tritt in der Mitte ein Fehler auf, werden die übrigen Streams nicht automatisch aufgeräumt – das kann zu Speicherlecks führen. Die Funktion stream.pipeline löst das. Sie verbindet die Streams, schließt sie im Fehlerfall sauber und ruft am Ende genau einen Callback auf.

const fs = require('fs');
const zlib = require('zlib');
const { pipeline } = require('stream');

pipeline(
  fs.createReadStream('daten.txt'),
  zlib.createGzip(),
  fs.createWriteStream('daten.txt.gz'),
  (err) => {
    if (err) {
      console.error('Pipeline fehlgeschlagen:', err.message);
    } else {
      console.log('Datei erfolgreich komprimiert.');
    }
  }
);

Hier liest Node die Datei, schickt die Chunks durch einen Gzip-Transform-Stream und schreibt das Ergebnis komprimiert zurück – alles Stück für Stück. Für async/await gibt es die passende Variante pipeline aus stream/promises.

Fazit

Streams sind eines der wichtigsten Konzepte in Node.js, weil sie dir erlauben, mit beliebig großen Datenmengen bei konstant niedrigem Speicherverbrauch zu arbeiten. Merke dir die vier Typen – Readable, Writable, Duplex und Transform – und nutze pipe() beziehungsweise pipeline(), um sie zu verketten. Sobald du das Prinzip verinnerlicht hast, wirst du Streams überall wiedererkennen: bei Dateien, HTTP-Requests, Kompression und Datenbank-Exporten. Fang klein an, kopiere eine Datei mit pipe() und baue dann deinen ersten eigenen Transform-Stream – so wird das Konzept schnell zur Selbstverständlichkeit.