Async Iterators and Generators in JavaScript
for...of loops over arrays and other iterables one value at a time. for await...of does the same thing when each value takes time to produce — reading paginated API results, streaming a large file, or consuming events as they arrive — without you having to manually juggle promises and loop state. The mechanism behind both is generators, and once you can write one, a whole category of "process this stream of things" code gets a lot simpler.
Regular generators: pausable functions
A generator function (function*) can pause itself with yield and resume later, keeping its local state intact between resumes.
function* countTo(n) {
for (let i = 1; i <= n; i++) {
yield i;
}
}
for (const value of countTo(3)) {
console.log(value); // 1, 2, 3
}
Calling countTo(3) doesn't run the function body — it returns an iterator. Each .next() call (which for...of does under the hood) runs the function until the next yield, returns that value, and pauses again. That's the whole mechanism: a function that can be resumed instead of only ever run start-to-finish.
Async generators: the same idea, with awaiting
An async generator (async function*) can await before yielding, so each value can depend on an asynchronous operation — a network request, a database query, a timer.
async function* fetchAllUsers() {
let page = 1;
while (true) {
const response = await fetch(`/api/users?page=${page}`);
const { users, hasMore } = await response.json();
yield* users; // yield each user individually, not the whole page
if (!hasMore) return;
page++;
}
}
for await (const user of fetchAllUsers()) {
console.log(user.name);
}
The consumer never has to know pagination is happening. It sees a single stream of user objects and processes them one at a time; the generator hides the paging loop, the fetch calls, and the "am I done yet" logic entirely.
Why not just return an array of promises?
The alternative — fetch every page up front, Promise.all them, flatten the result — works, but it means holding the entire dataset in memory and waiting for every page before processing the first record. An async generator yields as soon as each value is ready:
// Eager: waits for everything, holds it all in memory
async function fetchAllUsersEager() {
const pages = await Promise.all(
pageNumbers.map((p) => fetch(`/api/users?page=${p}`).then((r) => r.json())),
);
return pages.flatMap((p) => p.users);
}
// Lazy: starts producing values immediately, one page at a time
async function* fetchAllUsersLazy() {
// ...as above
}
For a dataset with 10,000 users across 100 pages, the eager version can't start processing user #1 until all 100 requests finish. The generator version starts processing user #1 as soon as page 1 arrives, and — just as importantly — a consumer that only needs the first 20 users can stop early without the generator ever fetching pages 2 through 100.
Stopping early actually stops the work
Breaking out of a for await...of loop calls the generator's .return(), which the runtime uses to run any pending finally blocks — this is how you release resources cleanly, not just how you stop iterating.
async function* readLines(filePath) {
const fileHandle = await openFile(filePath);
try {
let line;
while ((line = await fileHandle.readLine()) !== null) {
yield line;
}
} finally {
console.log('closing file handle');
await fileHandle.close();
}
}
for await (const line of readLines('access.log')) {
if (line.includes('ERROR')) {
console.log('found it:', line);
break; // triggers the generator's finally block — file still gets closed
}
}
Without the generator's finally, breaking early would leave the file handle open. This is the same guarantee a regular try/finally gives you around a loop, just extended to a resource whose lifetime spans multiple await points.
Building your own async iterable
Anything implementing Symbol.asyncIterator works with for await...of — you don't need a generator function specifically, though it's usually the easiest way to write one. This is useful for wrapping things that produce values over time but aren't generators themselves, like a WebSocket message stream:
function messagesFrom(socket) {
return {
[Symbol.asyncIterator]() {
const queue = [];
let resolveNext;
socket.addEventListener('message', (event) => {
if (resolveNext) {
resolveNext({ value: event.data, done: false });
resolveNext = null;
} else {
queue.push(event.data);
}
});
return {
next() {
if (queue.length > 0) {
return Promise.resolve({ value: queue.shift(), done: false });
}
return new Promise((resolve) => { resolveNext = resolve; });
},
};
},
};
}
for await (const message of messagesFrom(socket)) {
console.log('received:', message);
}
This turns an event-emitter style API into something you can loop over directly, instead of registering a callback and manually managing state outside the loop.
When to reach for this
Async generators are the right tool whenever you're processing a sequence of values that arrive over time and you want the consumer to stay ignorant of how they arrive — pagination, streaming responses, chunked file reads, WebSocket/SSE message streams. If you already have the full dataset in hand (say, the result of one fetch call), a plain array and for...of is simpler and there's no reason to introduce a generator. Reach for async function* specifically when "produce the next value" itself requires waiting.