Async Iterators and for-await-of
Async iterators let you iterate over data that arrives asynchronously — think of streaming API responses, WebSocket messages, or database query results. If you have ever wanted to use a for loop with something that takes time to produce each value, async iterators are the answer.
The Problem with Regular Iterators
Regular iterators work great for synchronous data:
function* generateNumbers() {
yield 1;
yield 2;
yield 3;
}
for (const num of generateNumbers()) {
console.log(num);
}
But what if generating each number takes time — maybe you are fetching from an API or reading from a large file? Regular generators cannot handle asynchronous operations inside:
// This does not work as you might expect
async function* generateAsync() {
const response = await fetch("/api/item/1");
yield await response.json(); // Syntax error!
}
You need async generators for this.
Async Generators
An async generator is a function that combines the async keyword with the generator syntax (*). It automatically returns an AsyncIterator object, and you use await inside it:
async function* fetchAllIds(ids) {
for (const id of ids) {
const response = await fetch(`/api/items/${id}`);
const data = await response.json();
yield data;
}
}
// Using it
async function main() {
for await (const item of fetchAllIds([1, 2, 3])) {
console.log(item);
}
}
Each yield pauses execution until the caller requests the next value. The caller can then use for-await-of to process items as they arrive.
The Async Iteration Protocol
Under the hood, async iterators implement the async iteration protocol. The Symbol object provides two key methods: Symbol.iterator for regular iterators and Symbol.asyncIterator for async iterators. Every async iterator has a .next() method that returns a Promise resolving to an object with two properties:
const asyncIterable = fetchAllIds([1, 2, 3]);
const iterator = asyncIterable[Symbol.asyncIterator]();
iterator.next().then(({ value, done }) => {
console.log(value); // First item
console.log(done); // false
});
This is different from regular iterators, where .next() returns the value directly:
const syncIterable = generateNumbers();
const iterator = syncIterable[Symbol.iterator]();
console.log(iterator.next().value); // 1 — synchronous!
The async version wraps everything in Promises, which is why you need await when calling .next() or when using for-await-of.
for-await-of in Detail
The for-await-of loop works with both async iterables and regular iterables that contain Promises:
// With async generator
async function* asyncNumbers() {
yield Promise.resolve(1);
yield Promise.resolve(2);
yield Promise.resolve(3);
}
async function demo() {
for await (const num of asyncNumbers()) {
console.log(num); // 1, 2, 3
}
}
You can also use it with an array of Promises:
const promises = [
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3)
];
for await (const num of promises) {
console.log(num);
}
If any Promise rejects, the loop throws and exits. Use try/catch if you need to handle failures gracefully:
async function demo() {
try {
for await (const item of riskyAsyncGenerator()) {
console.log(item);
}
} catch (err) {
console.error("Failed:", err);
}
}
Converting Async Iterators to Arrays
Sometimes you need all values at once instead of processing them one by one. You can collect async iterator results into an array:
async function* generateItems() {
yield { id: 1 };
yield { id: 2 };
yield { id: 3 };
}
async function toArray(iterable) {
const results = [];
for await (const item of iterable) {
results.push(item);
}
return results;
}
const items = await toArray(generateItems());
console.log(items); // [{id: 1}, {id: 2}, {id: 3}]
There is also a proposal for Iterator.prototype.toArray() that would make this more convenient, but it requires a polyfill in current environments.
Practical Use Cases
Streaming API Responses
If an API supports pagination, you can use an async generator to fetch pages until there are no more:
async function* fetchAllPages(baseUrl) {
let nextUrl = baseUrl;
while (nextUrl) {
const response = await fetch(nextUrl);
const data = await response.json();
yield* data.items; // Yield each item individually
nextUrl = data.nextPage; // undefined when done
}
}
async function processAllUsers() {
for await (const user of fetchAllPages("/api/users?page=1")) {
console.log(user.name);
}
}
This approach is memory-efficient because it never loads all users into memory at once.
WebSocket Streams
WebSockets deliver messages over time, which is a natural fit for async iteration:
async function* messageStream(ws) {
while (true) {
const message = await new Promise((resolve, reject) => {
ws.onmessage = event => resolve(event.data);
ws.onerror = reject;
ws.onclose = () => resolve(undefined);
});
if (message === undefined) break;
yield message;
}
}
Reading Files Line by Line
Node.js readable streams implement the async iteration protocol:
import { createReadStream } from "fs";
async function countLines(filepath) {
let count = 0;
const stream = createReadStream(filepath, { encoding: "utf8" });
for await (const chunk of stream) {
count += chunk.split("\n").length - 1;
}
return count;
}
This is much cleaner than the old stream.on(“data”) callback pattern.
Error Handling Strategies
When working with async iterators, errors can come from two places: the iterator itself or the loop body:
async function* problematicGenerator() {
yield 1;
throw new Error("Iterator failed");
}
async function demo() {
try {
for await (const value of problematicGenerator()) {
console.log(value);
}
} catch (err) {
console.error("Caught:", err.message);
}
}
If you need partial results even when errors occur, wrap individual items:
async function* safeGenerator() {
yield { ok: true, value: 1 };
yield { ok: false, error: "Failed" };
yield { ok: true, value: 3 };
}
async function demo() {
const results = [];
for await (const item of safeGenerator()) {
if (item.ok) {
results.push(item.value);
} else {
console.warn("Skipped:", item.error);
}
}
return results;
}
Combining with Promise.all()
If you want to process multiple items in parallel while still using async iteration, combine with Promise.all():
async function* fetchWithDelay(id) {
await new Promise(r => setTimeout(r, 100));
return { id, data: "some data" };
}
async function processBatch(iterable, batchSize = 3) {
const iterator = iterable[Symbol.asyncIterator]();
const results = [];
while (true) {
const batch = [];
for (let i = 0; i < batchSize; i++) {
const { done, value } = await iterator.next();
if (done) break;
batch.push(value);
}
if (batch.length === 0) break;
const processed = await Promise.all(
batch.map(item => transformItem(item))
);
results.push(...processed);
}
return results;
}
This gives you control over concurrency while keeping the async iteration pattern.
See Also
- JavaScript Generators — Understand the synchronous generator syntax that async generators extend
- Promises in Depth — Master the Promise fundamentals that async iteration builds upon
- JavaScript Symbols — Learn about Symbol.iterator and Symbol.asyncIterator