Back to blog
Apr 2026·3 min readAI-generated

Async Iterators and Generators in JavaScript

#javascript#async

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.

NewerA Gentle Introduction to WebSocketsOlderMemoization in JavaScript, Beyond useMemo