JavaScript Async Iteration and for-await-of: Streaming Data Chunk by Chunk
Async iteration is JavaScript's way of consuming a stream of values that arrive over time — fetch response bodies, file reads, paginated APIs, WebSocket messages, AI token streams. The syntax is for await...of, the protocol is AsyncIterable, and the producer side is the async generator — async function*. Once you internalize these three pieces, real-time data processing in JavaScript becomes as natural as Array.map.
This lesson covers the protocol, async generators, the consumption syntax, error handling, and the practical patterns for pagination and streams.
The two iteration protocols recap #
From Lesson 3.5 (iterators and generators) you saw the sync iteration protocol:
- An iterable has a
[Symbol.iterator]()method returning an iterator. - The iterator has a
.next()method returning{ value, done }. for...ofconsumes any iterable.
The async protocol mirrors it:
- An async iterable has
[Symbol.asyncIterator]()returning an async iterator. - The async iterator's
.next()returns a Promise of{ value, done }. for await...ofconsumes any async iterable, awaiting each.next()call.
const asyncIterable = {
async *[Symbol.asyncIterator]() {
yield 1;
await new Promise(r => setTimeout(r, 100));
yield 2;
yield 3;
},
};
for await (const n of asyncIterable) {
console.log(n);
}
// 1, then (after 100ms) 2, then 3
for await...of #
The consumer syntax. Works on:
- Any object implementing
[Symbol.asyncIterator] - Streams (Node
ReadableStream, Fetch response bodies in Node 18+) - Async generators
- Arrays of Promises (each Promise is awaited in order)
async function processArray(arr) {
for await (const value of arr.map(asyncFetch)) {
console.log(value);
}
}
Note: for await of an array of Promises runs them sequentially, not in parallel. For parallel, use Promise.all(arr.map(asyncFetch)). We covered this in Lesson 5.3.
Inside async functions only #
for await only works inside an async function or at the top level of an ES module (top-level await — Lesson 5.3).
Break and return #
Breaking out of a for await properly closes the iterator:
for await (const chunk of stream) {
if (chunk.type === 'end') break;
process(chunk);
}
// the iterator's .return() is called automatically here
This matters for streams that hold resources — break triggers cleanup.
Async generators #
The producer side. Defined with async function*:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const { data, nextPage } = await res.json();
for (const item of data) yield item;
next = nextPage;
}
}
for await (const item of paginate('/api/users')) {
console.log(item);
}
Three things in one function:
async— can await Promises insidefunction*— can yield values- Combined, it's an async iterable producer
Each yield produces one value to the consumer; the consumer's await pauses until the producer hits the next yield.
Async generators are the cleanest pattern for paginated APIs, polling, AI streams, and anything where data arrives in chunks over time.
A worked example: streaming an AI completion #
Many AI APIs return responses as a stream of tokens. Here's how that consumption looks:
async function* tokenStream(url, prompt) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, stream: true }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse server-sent-event lines
const lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete final line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try { yield JSON.parse(data).text; } catch {}
}
}
}
}
// Usage
for await (const token of tokenStream('/api/complete', 'Hello')) {
output.textContent += token; // live-typing UI
}
UI update per token, no library required. The same pattern handles Server-Sent Events, MJPEG streams, ndjson logs.
A working pagination helper #
async function* pagedFetch(url) {
let next = url;
while (next) {
const res = await fetch(next);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const json = await res.json();
yield* json.data; // yield each item individually
next = json.nextPage;
}
}
// Consume one at a time
for await (const user of pagedFetch('/api/users?page=1')) {
console.log(user.name);
}
// Or collect all
const all = [];
for await (const user of pagedFetch('/api/users?page=1')) {
all.push(user);
if (all.length >= 100) break; // stop early
}
The yield* syntax delegates to another iterable — here, the array of items in each page. Callers see a flat stream of users, no nesting.
Breaking out at 100 cancels the next page fetch — the generator goes through its cleanup and stops. No wasted HTTP request.
Error handling #
async function* mightFail() {
yield 1;
yield 2;
throw new Error('boom');
}
try {
for await (const n of mightFail()) {
console.log(n);
}
} catch (err) {
console.log('caught:', err.message); // 'caught: boom'
}
Errors propagate cleanly into try/catch around the for await. Same model as synchronous iteration with a thrown exception.
This is one of the biggest wins over callbacks or .on('data', ...) event-based streaming — error paths are linear.
Throttling and backpressure #
A classic problem: the producer is faster than the consumer.
With for await...of, the consumer automatically applies backpressure — the next await iterator.next() doesn't happen until the body of the loop finishes. If the body is slow, the producer is implicitly throttled.
for await (const item of fastStream) {
await slowSave(item); // blocks the next pull until slowSave completes
}
This is the cleanest backpressure model in any language with async. Streams (Node's, Web's) have explicit pause/resume, but for await makes them unnecessary in most cases.
Async iteration with concurrency #
If you want parallelism — but bounded — combine with a worker pool:
async function processWithConcurrency(asyncIterable, fn, concurrency = 4) {
const workers = Array(concurrency).fill(null).map(async () => {
for await (const item of asyncIterable) {
await fn(item);
}
});
await Promise.all(workers);
}
All workers share the same iterator. Each pulls one item at a time, processes it, pulls the next. Concurrency stays at the configured limit.
Libraries like p-map, bluebird provide more options (per-item concurrency, error policies).
Combining sync and async iteration #
A hybrid case: a sync iterable of Promises:
const urls = ['/a', '/b', '/c'];
// This is sequential — each fetch waits for the previous
for await (const res of urls.map(u => fetch(u))) {
console.log(await res.json());
}
// Parallel
await Promise.all(urls.map(async u => {
const res = await fetch(u);
console.log(await res.json());
}));
for await of on a sync array of Promises is the same as awaiting each one in order. Useful when you want sequential processing; harmful when you want parallel.
Real-world examples #
Node.js file reading #
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const stream = createReadStream('big.log');
const lines = createInterface({ input: stream });
for await (const line of lines) {
if (line.includes('ERROR')) console.log(line);
}
Line-by-line reading without loading the entire file.
Fetch streaming in the browser #
const res = await fetch('/api/big');
if (res.body) { // Web Streams
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
output.textContent += chunk;
}
}
Browsers expose response.body as a ReadableStream (covered in Lesson 10.3) which is async-iterable.
Polling #
async function* poll(url, intervalMs) {
while (true) {
const res = await fetch(url);
yield await res.json();
await new Promise(r => setTimeout(r, intervalMs));
}
}
for await (const status of poll('/api/job/123', 1000)) {
if (status.done) break;
console.log(status.progress);
}
Clean polling with explicit cleanup on break.
A summary #
for await...ofconsumes anyAsyncIterable. Requiresasyncfunction or module top-level.async function*produces values over time withyield, canawaitbetween yields.yield*delegates to another iterable. Use with arrays inside paginated fetchers.- Backpressure is automatic — slow consumer throttles fast producer.
- Errors propagate via try/catch — linear flow, unlike event-based streaming.
breaktriggers cleanup in the generator via its.return()slot.- For parallel processing, wrap the async iterable in a worker-pool helper.
What's next #
Lesson 10.3 covers the Streams API — ReadableStream, WritableStream, TransformStream, the modern primitive for plumbing chunks of data across browser and Node. Combined with async iteration, it's the full toolkit for streaming.
Try it yourself #
The automatic-backpressure behavior is the most surprising property. Predict the timing:
async function* gen() {
for (let i = 0; i < 5; i++) {
console.log('produced', i);
yield i;
}
}
for await (const n of gen()) {
await new Promise(r => setTimeout(r, 1000));
console.log('consumed', n);
}js_sandboxTotal: ~5 seconds.Logs interleave:
produced 0 → wait 1s → consumed 0produced 1 → wait 1s → consumed 1…
The producer doesn’t race ahead. The body’s
await pauses the loop, and the next iteration’s iterator.next() isn’t called until the body finishes. That’s backpressure — for free, with no manual pause/resume code.This property is why for await of won the streaming-syntax race. Sync code reads top-to-bottom; async iteration extends that property into time.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


