speichereffizient iterieren statt vollständig puffern
Ein Async Generator liefert Werte einzeln und bei Bedarf, statt eine komplette Ergebnisliste im Speicher zu halten. Für paginierte APIs, große Exporte und Live-Datenströme ersetzt async function* zusammen mit for await...of fehleranfällige Schleifen-Konstrukte durch lesbaren, komponierbaren Code.
Inhaltsverzeichnis
- 1. Warum Async Generatoren das richtige Werkzeug für Datenstreams sind
- 2. Syntax: async function* und yield im Detail
- 3. Konsumieren mit for await...of
- 4. Paginierte APIs als Async Generator abbilden
- 5. Kombination mit ReadableStream und Fetch-Body-Streaming
- 6. Backpressure und konsumentengesteuerte Iteration
- 7. Früher Abbruch und Cleanup mit return() und throw()
- 8. Composition: Generator-Pipelines bauen
- 9. Array-Sammlung vs Async Generator im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Async Generatoren das richtige Werkzeug für Datenstreams sind
Ein Async Generator ist eine Funktion, die Werte nicht auf einmal zurückgibt, sondern nacheinander produziert, sobald der Konsument den nächsten Wert anfragt. Der entscheidende Unterschied zu einer normalen asynchronen Funktion, die ein Array zurückgibt: Der Async Generator muss nicht erst alle Daten vollständig laden und im Speicher halten, bevor der Aufrufer mit der Verarbeitung beginnen kann. Bei einer paginierten API mit hundert Seiten bedeutet das, dass die Verarbeitung bereits nach der ersten Seite starten kann, statt auf alle hundert Antworten zu warten.
Diese Eigenschaft macht Async Generatoren zum natürlichen Werkzeug für Datenstreams jeder Art: CSV-Exporte mit Millionen Zeilen, Log-Dateien, die kontinuierlich wachsen, oder Ergebnisse aus einer Datenbank-Abfrage, die zu groß sind, um komplett in den Arbeitsspeicher zu passen. Statt einer Funktion, die am Ende ein riesiges Array zurückgibt und dabei den kompletten Speicherbedarf vorab reserviert, liefert ein Async Generator einen kontinuierlichen Fluss von Werten, den der Konsument in seinem eigenen Tempo abarbeitet.
2. Syntax: async function* und yield im Detail
Ein Async Generator wird mit der Kombination aus async und function* deklariert, also async function* meinGenerator() { }. Innerhalb des Funktionskörpers erzeugt yield wert den nächsten Wert des Streams und pausiert die Ausführung, bis der Konsument den nächsten Wert anfordert. Anders als bei einem synchronen Generator kann vor jedem yield ein await stehen, sodass sich asynchrone Operationen wie Netzwerk-Requests oder Datenbankabfragen nahtlos in den Fluss einfügen lassen.
Der Rückgabewert eines Aufrufs eines Async Generators ist kein Promise und kein direkter Wert, sondern ein AsyncGenerator-Objekt, das gleichzeitig Symbol.asyncIterator implementiert. Jeder Aufruf von .next() auf diesem Objekt liefert ein Promise, das zu { value, done } auflöst. Diese Struktur ist identisch zu der eines synchronen Iterators, nur dass jeder Schritt asynchron erfolgt, was Async Generatoren kompatibel mit jedem Code macht, der bereits das Async-Iterator-Protokoll versteht.
// A minimal async generator that yields values over time
async function* countWithDelay(limit, delayMs) {
for (let i = 1; i <= limit; i++) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
yield i; // pauses here until the consumer asks for the next value
}
}
// Calling it does not execute the body yet — it returns an async iterator
const generator = countWithDelay(3, 500);
console.log(await generator.next()); // { value: 1, done: false }
console.log(await generator.next()); // { value: 2, done: false }
3. Konsumieren mit for await...of
Die idiomatische Art, einen Async Generator zu konsumieren, ist die for await...of-Schleife. Sie ruft intern wiederholt .next() auf, wartet auf jedes Promise, extrahiert den value und beendet die Schleife automatisch, sobald done auf true steht. Für den Code, der die Daten verarbeitet, sieht das aus wie eine ganz normale for...of-Schleife über ein Array, obwohl im Hintergrund jeder Schritt asynchron abläuft und möglicherweise erst durch einen Netzwerk-Request ausgelöst wird.
Diese Symmetrie zur synchronen Iteration ist der eigentliche Gewinn von Async Generatoren gegenüber manuellem Promise-Chaining oder rekursiven then()-Aufrufen. Fehlerbehandlung funktioniert mit normalem try/catch um die Schleife herum, weil ein verworfenes Promise innerhalb des Generators als geworfene Exception an der Stelle der Schleife auftaucht, an der die Iteration gerade steht.
async function processCountStream() {
try {
for await (const value of countWithDelay(5, 300)) {
console.log('Received:', value);
}
console.log('Stream finished normally');
} catch (error) {
console.error('Stream failed:', error.message);
}
}
4. Paginierte APIs als Async Generator abbilden
Der häufigste Praxisfall für Async Generatoren ist das Abbilden paginierter REST-APIs. Statt einer Funktion, die alle Seiten lädt und am Ende ein zusammengeführtes Array zurückgibt, liefert ein Async Generator jede geladene Seite oder sogar jedes einzelne Element, sobald es verfügbar ist. Der Aufrufer kann die Verarbeitung starten, während im Hintergrund die nächste Seite bereits nachgeladen wird, und muss nicht warten, bis die letzte Seite der API beantwortet wurde.
Ein weiterer Vorteil zeigt sich bei Abbruchbedingungen: Wenn der Konsument nach den ersten fünfzig Elementen genug hat, etwa weil ein Suchtreffer bereits gefunden wurde, kann die Schleife mit break verlassen werden, ohne dass zuvor unnötig alle verbleibenden Seiten geladen wurden. Bei einer Funktion, die vorab das komplette Array zurückgibt, wären alle Requests bereits ausgeführt, bevor der Aufrufer überhaupt entscheiden kann, dass er nicht mehr braucht.
// Async generator wrapping a paginated REST API
async function* fetchAllUsers(apiUrl) {
let nextUrl = apiUrl;
while (nextUrl) {
const response = await fetch(nextUrl);
const page = await response.json();
for (const user of page.items) {
yield user; // yield individual items, not whole pages
}
nextUrl = page.nextPageUrl ?? null;
}
}
// Consumer stops early without loading remaining pages
for await (const user of fetchAllUsers('/api/users?limit=50')) {
if (user.email.endsWith('@mironsoft.de')) {
console.log('Found:', user);
break; // remaining pages are never fetched
}
}
5. Kombination mit ReadableStream und Fetch-Body-Streaming
Async Generatoren lassen sich elegant mit der ReadableStream-API kombinieren, die der Body einer Fetch-Antwort implementiert. Der Reader eines ReadableStream liefert Chunks über .read(), was strukturell dem .next()-Aufruf eines Async Generators entspricht. Eine dünne Wrapper-Funktion kann einen ReadableStream in einen Async Generator umwandeln und damit die Verarbeitung großer Downloads, etwa NDJSON- oder CSV-Antworten, mit derselben for await...of-Syntax ermöglichen, die auch für paginierte APIs genutzt wird.
Diese Vereinheitlichung ist besonders wertvoll, wenn eine Anwendung sowohl paginierte JSON-APIs als auch gestreamte Rohdaten verarbeiten muss. Beide Datenquellen lassen sich hinter derselben Async-Generator-Schnittstelle verstecken, sodass der konsumierende Code nicht wissen muss, ob die Daten von einer paginierten API oder einem kontinuierlichen Stream stammen. Das reduziert die kognitive Last für Teams, die mehrere Datenquellen mit unterschiedlichen Transportmechanismen unter einen Hut bringen müssen.
// Convert a fetch response body into lines via an async generator
async function* streamLines(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
yield buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
}
}
if (buffer) yield buffer; // trailing line without a newline
} finally {
reader.releaseLock();
}
}
6. Backpressure und konsumentengesteuerte Iteration
Backpressure beschreibt das Problem, dass ein schneller Produzent einen langsameren Konsumenten mit Daten überflutet, wenn keine Steuerung vorhanden ist. Async Generatoren lösen dieses Problem strukturell, weil der Produzentencode erst dann weiterläuft, wenn der Konsument tatsächlich .next() aufruft, entweder direkt oder implizit über for await...of. Es gibt keine Warteschlange, die unbegrenzt wächst, weil der Generator seine Ausführung an jedem yield tatsächlich pausiert, bis Bedarf besteht.
Das unterscheidet Async Generatoren fundamental von Event-Emitter-basierten Streaming-Ansätzen, bei denen Daten unabhängig von der Verarbeitungsgeschwindigkeit des Konsumenten emittiert werden und im schlimmsten Fall gepuffert werden müssen. Wer einen langsamen Datenbank-Export mit einem schnellen Konsumenten kombiniert, profitiert automatisch von dieser Pull-basierten Steuerung, ohne eigene Puffer- oder Drossel-Logik implementieren zu müssen.
7. Früher Abbruch und Cleanup mit return() und throw()
Wenn eine for await...of-Schleife über einem Async Generator vorzeitig mit break, return oder einer geworfenen Exception verlassen wird, ruft die Runtime automatisch .return() auf dem Generator auf. Das führt jeden offenen finally-Block innerhalb des Generators aus, was für das Schließen von Datenbank-Cursorn, Datei-Handles oder Netzwerkverbindungen entscheidend ist. Ohne diesen Mechanismus würden vorzeitig abgebrochene Iterationen Ressourcen offen halten, die niemand mehr aufräumt.
Der explizite Aufruf von generator.return(wert) ist auch manuell möglich, etwa wenn ein Timeout eine laufende Iteration von außen beenden soll. Ebenso kann generator.throw(fehler) genutzt werden, um eine Exception an der aktuellen Pausierungsstelle des Generators auszulösen, was ihm erlaubt, in einem eigenen try/catch-Block kontrolliert zu reagieren, statt abrupt beendet zu werden. Diese beiden Methoden machen Async Generatoren zu vollwertigen, kooperativ abbrechbaren Ressourcen.
async function* readDatabaseCursor(cursor) {
try {
while (await cursor.hasNext()) {
yield await cursor.next();
}
} finally {
// Runs on normal completion, early break, or thrown error
await cursor.close();
console.log('Cursor closed');
}
}
for await (const row of readDatabaseCursor(cursor)) {
if (row.id === targetId) break; // triggers cursor.close() via finally
}
8. Composition: Generator-Pipelines bauen
Async Generatoren lassen sich zu Pipelines verketten, ähnlich wie synchrone Array-Methoden wie map und filter, nur ohne dass jede Stufe eine vollständige Zwischen-Liste erzeugt. Eine Transformationsfunktion nimmt einen Async Generator entgegen, iteriert selbst mit for await...of darüber und gibt selbst wieder ein Async Generator-Objekt zurück, das die transformierten Werte liefert. Jede Stufe der Pipeline verarbeitet nur ein Element auf einmal, was den Speicherbedarf konstant hält, unabhängig von der Gesamtgröße des Datenstroms.
Dieses Muster erlaubt es, komplexe Datenverarbeitung aus kleinen, wiederverwendbaren Async-Generator-Funktionen zusammenzusetzen: eine Stufe filtert, eine zweite transformiert, eine dritte batcht Elemente zu Gruppen. Jede Stufe bleibt unabhängig testbar, weil sie nur einen Eingabe-Async Generator annimmt und einen Ausgabe-Async Generator zurückgibt, ohne Wissen über die übrigen Stufen der Pipeline zu benötigen.
// Reusable async generator transformation stages
async function* mapAsync(source, transform) {
for await (const item of source) {
yield transform(item);
}
}
async function* filterAsync(source, predicate) {
for await (const item of source) {
if (predicate(item)) yield item;
}
}
// Compose a pipeline: fetch -> filter -> transform
const activeUserNames = mapAsync(
filterAsync(fetchAllUsers('/api/users'), (u) => u.active),
(u) => u.name.toUpperCase()
);
for await (const name of activeUserNames) {
console.log(name);
}
9. Array-Sammlung vs Async Generator im Vergleich
Die folgende Tabelle stellt die klassische Methode, alle Ergebnisse in einem Array zu sammeln, dem Ansatz mit Async Generatoren gegenüber. Der Unterschied wird besonders bei großen Datenmengen und bei Abbruchszenarien sichtbar.
| Aspekt | Array sammeln | Async Generator | Vorteil |
|---|---|---|---|
| Speicherbedarf | Wächst mit Gesamtgröße | Konstant, ein Element zur Zeit | Keine Out-of-Memory-Risiken bei großen Streams |
| Zeit bis erste Verarbeitung | Erst nach allen Requests | Nach erstem Element | Schnellere gefühlte Reaktionszeit |
| Früher Abbruch | Alle Requests bereits ausgeführt | Verbleibende Requests entfallen | Weniger unnötige Netzwerk-Last |
| Backpressure | Nicht vorhanden | Eingebaut über Pull-Modell | Kein manuelles Puffer-Management |
| Composability | Zwischen-Arrays pro Stufe | Verkettbare Generator-Pipelines | Konstanter Speicherbedarf über alle Stufen |
Der Vergleich zeigt: Für kleine, feste Datenmengen bleibt das einfache Array oft die pragmatischere Wahl. Sobald Datenmenge, Streaming-Charakter oder Abbruchbarkeit relevant werden, ist der Async Generator die robustere Grundlage.
Mironsoft
Datenintensive JavaScript-Architektur und Streaming-Pipelines
Große Datenmengen ohne Out-of-Memory-Risiken verarbeiten?
Wir bauen speichereffiziente Streaming-Pipelines mit Async Generatoren für paginierte APIs, große Exports und Live-Datenquellen in eurer Anwendung.
Analyse
Speicherintensive Array-Sammlungen identifizieren und bewerten
Umbau
Migration auf Async-Generator-Pipelines mit Backpressure
Streaming
ReadableStream-Integration für Fetch-Body-Streaming
10. Zusammenfassung
Async Generatoren lösen das Problem, große oder kontinuierliche Datenmengen zu verarbeiten, ohne sie vollständig im Speicher zu puffern. Mit async function* und yield lassen sich paginierte APIs, gestreamte HTTP-Antworten und Datenbank-Cursor hinter derselben for await...of-Syntax verstecken, was den konsumierenden Code unabhängig von der konkreten Datenquelle macht.
Backpressure ist bei Async Generatoren strukturell eingebaut, weil der Produzent erst weiterläuft, wenn der Konsument tatsächlich Bedarf anmeldet. return() und throw() sorgen für zuverlässiges Cleanup bei vorzeitigem Abbruch, und die Verkettbarkeit zu Pipelines erlaubt es, komplexe Datenverarbeitung aus kleinen, unabhängig testbaren Bausteinen zusammenzusetzen, ohne dass Zwischenergebnisse als vollständige Arrays im Speicher landen.
Async Generatoren für Datenstreams — Das Wichtigste auf einen Blick
Syntax
async function* mit yield und optionalem await vor jedem yield. Liefert ein AsyncGenerator-Objekt zurück.
Konsum
for await...of iteriert über den Generator wie über ein Array, inklusive normalem try/catch.
Backpressure
Pull-basiertes Modell verhindert unkontrolliertes Puffern, der Produzent pausiert bis Bedarf besteht.
Cleanup
finally-Blöcke im Generator laufen zuverlässig bei break, return oder Exception im Konsumenten.