Helm & Sand
Engineering7 Min

Streaming in 80 Zeilen

Eine kleine Sandbox, die zeigt, warum ReadableStream mehr ist als fetch().body, und warum die meisten Anwendungen den Stream-Overhead nicht brauchen.

Aufgeschlagenes Notizbuch mit handgeschriebenen Pfeilen, die Datenfluss zwischen Blöcken zeigen.

Wer je versucht hat, eine 200-MB-Logdatei in den Browser zu laden, kennt das Problem: der Tab hängt, der Speicher wächst, und der User wartet. Die Lösung ist nicht schneller parsen, sondern gar nicht alles puffern.

Auf den Punkt

Wer die nächsten fünf Minuten nicht hat: Streams sind keine Performance-Maßnahme, sie sind Backpressure-Architektur. Wer eine 200-MB-Datei in einem Stück lädt, bezahlt dreifach: Netzwerk warten, Speicher halten, Garbage Collector pausieren. Wer zeilenweise liest, lässt den Browser atmen. Die Frage ist nicht "Stream ja oder nein", sondern "wo beginnt der Schmerz".

Glossar

Begriff
ReadableStream
Bedeutung
Die Web-API, die einen Datenstrom als Queue von Chunks bereitstellt. Statt einmal auf die ganze Antwort zu warten, liest der Konsument ein Chunk nach dem anderen und kann den Stream jederzeit abbrechen.
Begriff
Backpressure
Bedeutung
Der Mechanismus, mit dem ein langsamer Consumer einen schnellen Producer ausbremst, bevor der Speicher überläuft. Im Browser: das Read-Promise wartet, bis der Consumer das letzte Chunk verarbeitet hat.
Begriff
Chunk
Bedeutung
Eine kleine, typischerweise 64 KB große Datenmenge, die der Browser über die HTTP-Verbindung streamt. Die Größe ist implementation-defined und für den Code transparent.
Begriff
Pipe Through
Bedeutung
Eine Stream-Operation, die einen ReadableStream in einen TransformStream leitet (z. B. TextDecoderStream). Das Ergebnis ist ein neuer ReadableStream, ohne den Original-Stream zu verändern.

Das Bild der Pipeline

write read backpressure Producer Buffer Consumer
Eine Stream-Pipeline: Producer schreibt in einen Buffer, Consumer liest zeilenweise. Backpressure bremst den Producer, wenn der Buffer voll ist.

Wer Streams zum ersten Mal sieht, kommt aus einer Welt von "Anfrage, komplette Antwort, weiter". Diese Welt funktioniert für ein 50-KB-JSON, scheitert aber an 200-MB-Logs. Streams verschieben die Frage von "wann ist alles da?" zu "wie schnell kann der Konsument die nächsten Chucks verarbeiten?".

Der Buffer in der Mitte ist kein Zufall. Er ist die Schnittstelle, an der die Geschwindigkeit zweier Welten ausgeglichen wird. Producer und Consumer müssen nicht synchron laufen, sie müssen nur über die Buffergröße miteinander reden. Das ist die Architektur, die das Internet seit Jahrzehnten trägt, ohne dass es jemandem aufgefallen ist.

Der naive Pfad

const response = await fetch('/logs/2026-08-22.jsonl');
const text = await response.text();
const lines = text.split('\n').map(JSON.parse);

Drei Allokationen, von denen zwei den gesamten Body im Speicher halten. Bei 200 MB landen 600 MB auf dem Heap, plus Garbage-Collection-Pausen im 100-ms-Bereich.

Die ersten 50 KB sind schnell, dann beginnt der DOM-Thread zu blockieren, weil der Garbage Collector die großen Strings aufräumen muss. Auf einem MacBook Air mit 8 GB RAM ist der Tab nach 30 Sekunden unbenutzbar, auf einem Android-Telefon ist er es nach 10. Wer in dieser Schleife steckt, versucht meistens, das Problem mit Web-Workern oder mit chunked-manifest-Workarounds zu lösen. Das Problem liegt eine Schicht früher.

Der andere Pfad

const response = await fetch('/logs/2026-08-22.jsonl');
const reader = response.body
  .pipeThrough(new TextDecoderStream())
  .getReader();

let buffer = '';
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  let nl;
  while ((nl = buffer.indexOf('\n')) >= 0) {
    const line = buffer.slice(0, nl);
    buffer = buffer.slice(nl + 1);
    process(line);
  }
}

Der Buffer hält nur den unvollständigen letzten Eintrag. value ist ein typisierter Array, das vom Browser zeilenweise freigegeben wird, sobald wir ihn lesen. Der Garbage Collector hat nichts Großes zum Aufräumen.

Das Muster ist alt, fast 30 Jahre alt. Es heißt Event-Loop-Iteration mit manuellem Buffer, und es funktioniert in Node.js, im Browser und in Deno identisch. Wer es einmal verstanden hat, sieht die gleichen Muster in ganz anderen Kontexten wieder.

Was die Fetch-API anders macht

Die fetch-API ist seit 2017 nativ streaming-fähig, auch wenn das in den meisten Code-Beispielen nicht sichtbar ist. Der Trick ist, dass await fetch() standardmäßig auf den kompletten Body wartet, bevor es auflöst. Wer den Body-Stream direkt konsumieren will, muss auf response.body zugreifen, einen ReadableStream<Uint8Array> erhalten, und ihn selbst weiterverarbeiten.

Drei Punkte, die in der Praxis übersehen werden:

Fetch ist nicht asynchron zu dem Body. Der Headers-Lesevorgang löst das Promise, sobald die HTTP-Header angekommen sind. Der Body ist ein zweiter Schritt, der je nach Konfiguration blockiert oder nicht. Wer await response.json() schreibt, wartet auf den ganzen Body, bevor das Promise auflöst. Wer response.body.getReader() direkt nutzt, kann den ersten Chunk schon verarbeiten, während die Header noch nicht einmal vollständig sind.

Content-Encoding ändert den Stream. Wer mit Brotli-komprimierten Responses arbeitet, bekommt den dekomprimierten Stream erst, wenn der Browser die Header gelesen hat. Bei kleinen Responses ist das ein nicht messbarer Overhead, bei 50-MB-Files sind es 200 ms, die sich zwischen Header-Empfang und erstem Chunk verstecken.

Streams sind nicht abbrechbar per Default. Wer einen Stream startet und dann feststellt, dass der User ihn nicht mehr braucht, muss den Stream aktiv abbrechen mit reader.cancel(). Sonst läuft der Netzwerk-Request weiter, der Browser bezahlt die Daten, und der GC räumt sie am Ende weg. Das ist eine der häufigsten versteckten Ineffizienzen in Single-Page-Apps.

Die wahren Kosten im Vergleich

Naiv 600 Stream + Buffer 115 Stream ohne Buffer 12
Speicherbedarf bei einer 200-MB-Logdatei. Naive Pfade verdreifachen den Heap-Bedarf; Stream-Pfade halten ihn unter Kontrolle.

Der Stream-Pfad ist nicht schneller, er ist gleich schnell. Er ist gerechter. Er erlaubt dem Browser, die Connection-Backpressure zu respektieren, und gibt dem UI-Thread Luft, während die Daten weiter fließen.

Die Messungen, die ich in den letzten Monaten auf vier Projekten gemacht habe, zeigen konsistent dasselbe Muster:

  • Naiv: 600 MB Heap, 4-6 GC-Pausen à 80-120 ms, Tab 25-40 Sekunden unresponsive
  • Stream mit Buffer: 80-150 MB Heap, 0-1 GC-Pausen, Tab durchgehend interaktiv
  • Stream ohne Buffer: 8-15 MB Heap, keine GC-Pausen, aber komplexere Fehlerbehandlung

Die Wahl ist nicht "Stream oder nicht". Sie ist "wie viel Komplexität willst du dir dafür erkaufen, dass dein Tab nicht einfriert".

Wann Streams unnötig sind

Die meisten Anwendungen brauchen sie nicht. Wer eine kleine JSON-Antwort parst, ein Bild lädt oder einen Static Site rendert, hat kein Stream-Problem. Wer eines hat, merkt es daran, dass der Browser-Tab hängt oder der Speicher unerklärlich wächst. Die einfache Frage ist: "Blockiert meine Response den UI-Thread länger als 100 ms?"

  • JSON < 1 MB: parsen in einem Rutsch ist einfacher zu debuggen, und der Allokations-Overhead ist unter 5 ms.
  • Bilder, Fonts, Video: Browser machen das schon.
  • Static Sites: Der Server sendet den vollständigen Body sowieso in einem Stück; was hinten rauskommt, ist die Frage des Renderers, nicht des Netzwerks.

Wann Streams Pflicht werden

  • Server-Sent Events, die nie enden.
  • Logs, Traces, Metrics mit hohem Volumen.
  • Generative AI Responses, Token-Stream statt Full-Response.

Frage

Brauche ich ReadableStream?

Antwort

Erst, wenn deine Response den UI-Thread länger als 100 ms blockiert. Vorher ist es premature engineering.

Eine kleine Anekdote aus der Praxis

Es war Anfang 2026, ein Log-Viewer für ein Produktionssystem. Die größte Logdatei war 380 MB. Der naive Pfad lud sie in 14 Sekunden, dann fror der Tab für 9 Sekunden, weil der GC die Strings aufräumte. Wir haben auf Streams umgestellt, ohne den Algorithmus zu ändern. Nach dem Refactoring lud dieselbe Datei in 11 Sekunden, der Tab blieb während des Ladens interaktiv, und der maximale Heap-Verbrauch fiel von 1.1 GB auf 90 MB.

Die Code-Diff war 23 Zeilen. Die Performance-Verbesserung war Faktor 12 im Worst-Case-Speicher. Die Ladezeit hat sich nur um 3 Sekunden verbessert, weil die Netzwerkbandbreite der limitierende Faktor war, nicht der Speicher. Wer den Refactoring nicht gemacht hätte, hätte weiterhin einen Tab, der nach dem Laden eines Tages Logs unbrauchbar ist.

Konkret

Wer das selbst ausprobieren will, drei Sandbox-Beispiele in aufsteigender Komplexität. Die Dateien sind minimal, die Messungen dauern jeweils ein paar Minuten.

Die Sandbox zeigt: ein Generator ohne Stream, ein Stream mit Backpressure, ein Stream ohne Buffer-Behandlung. Das Messen zeigt, wo der Aufwand wirklich sitzt. Es ist fast nie der Stream selbst, es ist immer die Fehlerbehandlung drumherum.

Weiterführend

  • WHATWG, Streams Standard - die formale Spezifikation der Web-Stream-API. Empfehlenswert, wenn man verstehen will, warum die API so aussieht, wie sie aussieht.
  • Jake Archibald, Streams (HTML5Rocks-Archiv) - die praxisorientierte Einführung, die die Mentalität der API am besten erklärt.
  • Node.js, Stream API Documentation - wer serverseitig mit Streams arbeitet, kommt um die Node-Doku nicht herum. Die Konzepte sind dieselben wie im Browser, die API ist eine andere.