Skip to content
JavaScript js async 4 min read

Async Iterators & Generators

Sometimes data doesn’t arrive all at once — it trickles in over time: pages from a paginated API, lines from a file, chunks from a network response. Async iteration is the language feature built for exactly this. With for await...of and async generators (async function*), you can loop over values that resolve asynchronously as cleanly as you loop over an array, pulling each item only when you’re ready for it. This gives you backpressure-friendly streaming without callback nesting or manual promise bookkeeping.

The async iterable protocol

A regular iterable implements Symbol.iterator, whose next() returns { value, done } synchronously. An async iterable implements Symbol.asyncIterator instead, and its next() returns a promise that resolves to { value, done }. That single difference is what lets each step of the loop wait for work to finish before delivering a value.

You rarely write this protocol by hand, but seeing it makes the magic concrete.

const range = {
  [Symbol.asyncIterator]() {
    let n = 0;
    return {
      next() {
        if (n >= 3) return Promise.resolve({ value: undefined, done: true });
        return Promise.resolve({ value: n++, done: false });
      },
    };
  },
};

Looping with for await…of

The for await...of statement consumes an async iterable. On every pass it awaits the promise from next(), unwraps the value, and runs the loop body. It works on async iterables and on plain iterables of promises, automatically awaiting each one. Like top-level await, it must live inside an async function or at the top level of an ES module.

async function run() {
  for await (const value of range) {
    console.log("got", value);
  }
  console.log("done");
}
run();

Output:

got 0
got 1
got 2
done

A common everyday use is awaiting an array of promises in arrival order of the loop — but note this still resolves them sequentially, one iteration at a time.

const urls = ["/a.json", "/b.json"];
for await (const res of urls.map((u) => fetch(u))) {
  console.log(res.status);
}

Tip: for await...of awaits items one at a time. If you need to fetch everything concurrently and only need the final array, prefer await Promise.all(urls.map(fetch)) instead — async iteration is for streaming, not for parallelizing independent work.

Async generators

Writing the protocol manually is tedious. An async generator — declared async function* — produces an async iterable automatically. Inside it you can await between yields and yield values that the consumer pulls on demand. Each yield suspends the generator until the consumer asks for the next value, which means producers and consumers stay naturally in lockstep.

async function* countdown(from, ms) {
  for (let n = from; n > 0; n--) {
    await new Promise((r) => setTimeout(r, ms));
    yield n;
  }
}

async function main() {
  for await (const n of countdown(3, 500)) {
    console.log(n);
  }
  console.log("liftoff");
}
main();

Output:

3
2
1
liftoff

Use case: paging an API

Paginated endpoints are the textbook fit. An async generator can hide all the cursor logic and yield one record at a time, so the caller writes a flat loop and never sees a page boundary.

async function* fetchAllUsers(baseUrl) {
  let page = 1;
  while (true) {
    const res = await fetch(`${baseUrl}/users?page=${page}`);
    const { data, hasMore } = await res.json();
    for (const user of data) {
      yield user;
    }
    if (!hasMore) return;
    page++;
  }
}

async function listNames() {
  for await (const user of fetchAllUsers("https://api.example.com")) {
    console.log(user.name);
    // break here and the generator simply stops fetching more pages
  }
}

Because values are produced lazily, breaking out of the loop early stops the fetching entirely — you never download pages you don’t read.

Use case: streaming a response body

In modern browsers and Node, Response.body is a ReadableStream, and recent runtimes make streams async iterable. You can loop over the chunks as they arrive instead of buffering the whole payload.

async function streamText(url) {
  const res = await fetch(url);
  const decoder = new TextDecoder();
  let received = 0;
  for await (const chunk of res.body) {
    received += chunk.length;
    process.stdout.write(decoder.decode(chunk, { stream: true }));
  }
  console.log(`\nReceived ${received} bytes`);
}

Cleanup, errors, and helpers

If the consumer exits the loop early (via break, return, or a thrown error), the engine calls the generator’s return() method, which resumes it at the suspended yield as though return were executed there. That means a finally block inside an async generator is the right place to release resources like file handles or sockets.

async function* withCleanup() {
  try {
    yield 1;
    yield 2;
  } finally {
    console.log("cleanup ran");
  }
}

Errors thrown inside the generator (or inside an awaited promise) propagate out of for await...of and can be caught with an ordinary try/catch around the loop.

ConceptSync versionAsync version
Iterable hookSymbol.iteratorSymbol.asyncIterator
next() returns{ value, done }Promise<{ value, done }>
Loop syntaxfor...offor await...of
Generatorfunction*async function*

Best Practices

  • Reach for async iteration when data arrives over time (pages, chunks, events); use Promise.all for fixed sets of independent work.
  • Wrap for await...of in try/catch to handle rejections, and add a finally inside generators to release resources.
  • Let early break cancel upstream work — design generators so they only fetch the next page or chunk when actually pulled.
  • Don’t expect concurrency from for await...of; it processes items strictly one at a time.
  • Use async function* to encapsulate pagination or cursor logic so callers see a single flat stream of items.
  • Decode streamed text with TextDecoder({ stream: true }) so multi-byte characters split across chunks aren’t corrupted.
Last updated June 1, 2026
Was this helpful?