jsguides

Async Iterators and for-await-of

Async iterators let you iterate over data that arrives asynchronously: 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, such as fetching from an API or reading from a large file? Regular generators cannot handle asynchronous operations inside. The await keyword is not valid inside a regular generator function, and trying to combine async with yield produces a syntax error:

// This does not work as you might expect
async function* generateAsync() {
  const response = await fetch("/api/item/1");
  yield await response.json(); // Syntax error!
}

The fix is to use an async generator, which is a function marked with both async and the generator * syntax. It automatically returns an AsyncIterator and lets you await between yields.

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 for-await-of loop automatically calls .next() on each iteration, unwraps the Promise, and assigns the resolved value to the loop variable. This means you process items one at a time as they become available, without loading everything into memory.

The async iteration protocol

Under the hood, async iterators implement the async iteration protocol. Every async iterator has a .next() method that returns a Promise resolving to { value, done }. You can call it manually to understand how for-await-of works:

const asyncIterable = fetchAllIds([1, 2, 3]);
const asyncIterator = Symbol.asyncIterator;
const iterator = asyncIterable[asyncIterator].call(asyncIterable);

iterator.next().then(({ value, done }) => {
  console.log(value); // First item
  console.log(done);  // false
});

This manual call shows the underlying mechanism: each .next() returns a Promise that resolves when the next value is ready. Getting Symbol.asyncIterator from the iterable and calling it gives you the iterator object. Regular iterators skip the Promise layer entirely and return values synchronously:

const syncIterable = generateNumbers();
const syncIterator = Symbol.iterator;
const iterator = syncIterable[syncIterator].call(syncIterable);

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. The loop handles the Promise unwrapping for you, making async iteration feel as natural as a regular for loop.

for-await-of in detail

The for-await-of loop works with both async iterables and regular iterables that contain Promises. The simplest case is an async generator that yields resolved 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
  }
}

The loop waits for each Promise to resolve before proceeding to the next iteration, so values arrive in order. You can also iterate directly over an array of Promises without a generator at all:

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 immediately, skipping all remaining values. This makes error handling straightforward: wrap the loop body in try/catch to handle failures without losing the entire iteration:

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. Wrapping for-await-of in a helper function lets you collect async iterator results into a plain 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. The yield* expression delegates to each item in the page, flattening the nested data into a single stream of individual user objects. The loop ends when nextPage is undefined.

WebSocket Streams

WebSockets deliver messages over time, which is a natural fit for async iteration. Each incoming message becomes the next yielded value, and the loop ends when the connection closes:

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;
  }
}

The Promise wraps each WebSocket event so the async generator can await it. When the connection closes, the Promise resolves to undefined, the loop breaks, and the generator finishes cleanly.

Reading files line by line

Node.js readable streams implement the async iteration protocol natively. You can iterate over chunks without the old stream.on('data') callback pattern:

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. The loop reads chunks sequentially, and the stream’s built-in backpressure prevents memory from growing unbounded.

Error handling strategies

When working with async iterators, errors can come from two places: the iterator itself or the loop body. If the generator throws, the loop catches it:

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);
  }
}

The try/catch wraps the entire loop, so a single error stops all remaining iterations. When you need to keep going past individual failures, wrap each value in a result object that carries its own success or error status:

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, you can batch items from the iterator and process them together 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 asyncIterator = Symbol.asyncIterator;
  const iterator = iterable[asyncIterator].call(iterable);
  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. The loop reads batchSize items at a time from the iterator, then processes them in parallel with Promise.all. This way you never load more than batchSize items into memory at once, which is a pragmatic middle ground between sequential processing and unbounded parallelism.

Async iterators and streams

Async iterators and streams solve related problems, but they are not identical. A stream is usually the better fit when you are moving data in chunks with built-in backpressure, especially for browser or Node APIs that already speak stream semantics. An async iterator is often easier when you want a language-level pattern that fits custom control flow or a sequence of awaited operations.

That difference matters when you are choosing abstractions for a new module. If the data source already behaves like a stream, adapt to it instead of rebuilding the same idea by hand. If the work is more about fetching one item at a time, an async iterator can be simpler and easier to test. The right choice is the one that makes the data flow easiest to explain.

Cleanup and Cancellation

Async iterators should make it clear how resources are released. If a generator opens a file, starts a request, or allocates other state, it should also have a clear cleanup path when iteration stops early. That keeps partially consumed sequences from leaving work behind. A good iterator behaves well whether the caller reads everything or stops after a few items.

Cancellation should be visible in the design. If a consumer can stop midway, the producer should know what to do next. That might mean closing handles, discarding a pending batch, or simply marking the iterator as finished. The important part is that the sequence ends cleanly instead of depending on luck.

Batching and Backpressure

Sometimes you want the convenience of async iteration but still need to process several items at once. In that case, batching is a useful middle ground. Read a small group of values, process them together, and then continue. That pattern can improve throughput without forcing the code to load everything into memory at once.

Backpressure is the idea that the consumer should set the pace. Even when you build your own loop, you can still apply that idea by waiting for each batch to finish before reading the next one. That keeps the program predictable and helps you avoid flooding slower downstream work.

Knowing when to stop

Async iteration is easiest to maintain when the stop condition is obvious. A loop should know when the source is finished, when the consumer has enough data, or when an error means the work should end early. That clarity keeps the flow easy to follow and reduces the chance that a loop keeps running after the useful work is done.

If you are building a generator for other people to consume, document the exit behavior clearly. Consumers need to know whether they can stop early, whether cleanup happens automatically, and what kind of values or errors they should expect at the end of the sequence. Good end-of-stream behavior makes the whole pattern feel safer.

See Also